scala Tutorial

Building an Expense Tracker with Scala 3 - Part 3: Authentication

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