scala Tutorial
Building an Expense Tracker with Scala 3 - Part 3: Authentication
In this series
- •Building an Expense Tracker with Scala 3 - Part 1: Setup
- •Building an Expense Tracker with Scala 3 - Part 2: Database & Docker
- •Building an Expense Tracker with Scala 3 - Part 3: Authentication
- •Building an Expense Tracker with Scala 3 - Part 4: Expenses & Categories
- •Building an Expense Tracker with Scala 3 - Part 5: Internationalization & Testing
In this part, we will implement authentication for our Expense Tracker. Security is paramount in any application handling financial data. We'll support both Email/Password login and Google OAuth to give users flexibility.
We will use JWT (JSON Web Tokens) for session management. This allows our API to be stateless, which is great for scalability and works perfectly with our React frontend.
User Model
First, let's define our User model. We'll use a Scala case class to represent the user data. Case classes are immutable by default and come with built-in methods for pattern matching, equality, and copying.
// app/models/User.scala
package models
import java.util.Date
case class User(
id: Option[Long], // Option because it's None before we insert it into the DB
email: String,
name: String,
passwordHash: Option[String], // Option because Google users won't have a password
googleId: Option[String],
createdAt: Date
)
User Repository
We need a way to interact with the database. We'll create a UserRepository that uses our DatabaseService from Part 2. This repository pattern keeps our database logic separate from our business logic (controllers).
// app/repositories/UserRepository.scala
package repositories
import models.User
import services.DatabaseService
import java.sql.{ResultSet, Statement}
import java.util.Date
class UserRepository(db: DatabaseService) {
// Find a user by email to check if they exist during login/registration
def findByEmail(email: String): Option[User] = {
db.withConnection { conn =>
val stmt = conn.prepareStatement("SELECT * FROM users WHERE email = ?")
stmt.setString(1, email)
val rs = stmt.executeQuery()
if (rs.next()) Some(mapRow(rs)) else None
}
}
// Create a new user and return their generated ID
def create(user: User): Long = {
db.withConnection { conn =>
val stmt = conn.prepareStatement(
"INSERT INTO users (email, name, password_hash, google_id, created_at) VALUES (?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)
stmt.setString(1, user.email)
stmt.setString(2, user.name)
stmt.setString(3, user.passwordHash.orNull) // Handle Option
stmt.setString(4, user.googleId.orNull)
stmt.setTimestamp(5, new java.sql.Timestamp(user.createdAt.getTime))
stmt.executeUpdate()
val rs = stmt.getGeneratedKeys
if (rs.next()) rs.getLong(1) else throw new Exception("Failed to create user")
}
}
// Helper to map a ResultSet row to a User object
private def mapRow(rs: ResultSet): User = {
User(
id = Some(rs.getLong("id")),
email = rs.getString("email"),
name = rs.getString("name"),
passwordHash = Option(rs.getString("password_hash")),
googleId = Option(rs.getString("google_id")),
createdAt = rs.getTimestamp("created_at")
)
}
}
Auth Controller
Now, let's create the AuthController to handle login and registration requests. We'll use Play's JSON library to parse the request body and validate the input.
// app/controllers/AuthController.scala
package controllers
import models.User
import play.api.mvc.*
import play.api.libs.json.*
import repositories.UserRepository
import java.util.Date
import scala.concurrent.ExecutionContext
class AuthController(cc: ControllerComponents, userRepo: UserRepository)(implicit ec: ExecutionContext) extends AbstractController(cc) {
// Define a DTO (Data Transfer Object) for the login request
case class LoginRequest(email: String, password: String)
// Create a JSON reader for the LoginRequest
implicit val loginReads: Reads[LoginRequest] = Json.reads[LoginRequest]
def login = Action(parse.json) { request =>
// Validate that the JSON body matches our LoginRequest structure
request.body.validate[LoginRequest].map { loginData =>
userRepo.findByEmail(loginData.email) match {
case Some(user) =>
// In a real app, use BCrypt to verify the password hash!
// NEVER store plain text passwords.
if (user.passwordHash.contains(loginData.password)) {
Ok(Json.obj(
"token" -> "fake-jwt-token", // Replace with real JWT generation
"user" -> Json.obj("name" -> user.name, "email" -> user.email)
))
} else {
Unauthorized("Invalid credentials")
}
case None => Unauthorized("Invalid credentials")
}
}.getOrElse(BadRequest("Invalid request format"))
}
// ... register method would be similar
}
Wiring and Routes
Don't forget to wire the new components in AppLoader.scala and add the routes in conf/routes.
// app/AppLoader.scala
lazy val authController: AuthController = wire[AuthController]
lazy val userRepository: repositories.UserRepository = wire[repositories.UserRepository]
# conf/routes
POST /api/auth/login controllers.AuthController.login
POST /api/auth/register controllers.AuthController.register
Testing
You can now test the registration and login endpoints using curl or Postman.
In the next part, we will build the core functionality: Expenses and Categories.
Continue to Part 4: Expenses & Categories
