Implementing the authentication service Ở phần trước chúng ta đã xây dựng được giao diện của trang login tương đối cơ bản, trong phần tiếp theo này chúng ta tiếp tục xây dựng phần xử lí logic authentication.Trước hết chúng ta cần một class User có chứa thông tin về người dùng.

package model 
import java.util.UUID 
case class User(userId: UUID, userCode: String, password: String).

Khi class User ánh xạ thông tin từ cơ sở dữ liệu, chúng ta có thể định nghĩa một phương thức helper tạo ra một instance mới của class User từ một tập kết quả:

object User { 
  def fromRS(rs: WrappedResultSet): User = { 
    User(UUID.fromString(rs.string("user_id")), 
      rs.string("user_code"), rs.string("password")) 
  } 
}

Lớp WrappedResultSet được định nghĩa trong scalikejdbc có một số method để lấy giá trị từ tập kết quả, có nhiều methods trả về giá trị của nhiều kiều khác nhau, ví dụ như string thì return String, short thì return Short.Ngoài ra có một số method với tên kết thúc là Opt, ví dụ như stringOpt.Chúng sẽ trả về Option values, hoặc None khi database trả về NULL và nhiều khi không có giá trị.Nếu chúng ta có có các cột không có khả năng bị null trong table chúng ra không cần thiết phải sử dụng Opt-methods.

Cốt lõi của authentication system của chúng ta là class services.AuthService.Nó là một public API sẽ bao gồm login method, và chúng ta sẽ pass các thông tin người dùng như là các đối số tại đây.Tùy thuộc vào tính hợp lệ của cá thông tin người dùng, chúng ta sẽ trả lại một cookie được wrap như Some[Cookie] hoặc None.Điều này đưa chúng ta đến với phương pháp đăng nhập với dấu hiệu sau:

class AuthService { 
  def login(userCode: String, password: String): Option[Cookie] = ??? 
}

Method login có thể dược chia thành 2 phần: xác minh thông tin người dùng và tạo ra một cookie mới.Theo như login này thì mỗi phần sẽ được tách ra như là một helper riêng biệt.

class AuthService { 
  // ... 
  private def checkUser(userCode: String, password: String): Option[User] = ??? 
  private def createCookie(user: User): Cookie = ??? 
}

Tại sao checkUser method trả về một Option trong khi createCookie trả về một giá trị thông thường.Ok, không sao cả, lí do là người dùng có thể cung cấp sai thông tin do đó việc kiểm tra có thể thất bại.Mặt khác, sau khi người dùng được định danh, việc create cookie không thể thất bại.Chúng ta cũng cần lưu trữ cookie được generate để check sau này.Để đơn giản chúng ta sử dụng Map[String, User] tại đây nhưng trong trường hợp này chúng ta cũng cần phải xử lí cookie đã hết hạn.Một cách khác là chúng ta xử dụng Play Cache API sẽ xử lí được cookie hết hạn cho chúng ta.Để sử dụng trong AuthService chúng ta cần pass qua nó như một constructor parameter:

class AuthService(cacheApi: CacheApi) { 
  // ... 
}

Play sử dụng EhCache, một Java library phổ biến để cài đặt CacheApi.Các cache module, đã sẵn sàng trong build.sbt file, do đó chúng ta không cần để thêm bất cứ thứ gì ở đây:

libraryDependencies ++= Seq( 
  jdbc, 
  cache, 
  ws, 
  // ... 
)

Việc tiếp theo chúng ta cần làm là add EhCacheComponents trait vào AppComponents:

trait AppComponents extends BuiltInComponents with AhcWSComponents 
  with EvolutionsComponents with DBComponents with HikariCPComponents 
  with EhCacheComponents { 
    // ... 
    lazy val authService = new AuthService(defaultCacheApi) 
    // ... 
}

Các EhCacheComponents trait giơis thiệu 2 thành viên của kiểu CacheApi vào scope.Như wire marco chọn các tham số dự trên kiểu của nó, nó không làm việc ở đây và chúng ta hiện thực AuthService bằng tay.

Trở lại chuyện generate cookies và nghĩ cho thời điểm thuộc tính nào là tốt cho security tokens.Vif chúng ta đang sử dụng token cho authenticate, token phải rất khó(gần như là lý tưởng).Người dùng khác nhau phải có token khác nhau vì vậy chúng ta nên sử dụng một số user id dựa trên việc cookie generation.Lấy tất cả những thông tin này cho account chúng ta sẽ cài đặt như sau:

class AuthService(cacheApi: CacheApi) {
  val mda = MessageDigest.getInstance("SHA-512")
  val cookieHeader = "X-Auth-Token"
  // ...
  private def createCookie(user: User): Cookie = {
    val randomPart = UUID.randomUUID().toString.toUpperCase
    val userPart = user.userId.toString.toUpperCase
    val key = s"$randomPart|$userPart"
    val token = Base64.encodeBase64String(mda.digest(key.getBytes))
    val duration = Duration.create(10, TimeUnit.HOURS)
    cacheApi.set(token, user, duration)
    Cookie(cookieHeader, token, maxAge = Some(duration.toSeconds.toInt))
  }
}

Chúng ta đang áp dụng một hàm băm để cho tất cả các token có độ dài bằng nhau, cookie được tạo ra sẽ có giá trị 10 giờ, vì vậy chúng ta đang chỉ đường cho Play Cache và các trình duyệt loại bỏ các cookie hết hạn sau một thời gian được sử dụng. CheckUser helper method phải thực hiện 2 nhiệm vụ, thứ nhất là phải tìm ra bản ghi người dùng trong database và sau đó check xem mật khẩu được cung cấp phù hợp với những gì chúng ta đã có trong bảng User chưa. Đối với các truy vấn cơ sở dữ liệu ScalikeJDBC có một đối tượng tên là DB đi kèm một số method.Chúng ta sẽ xử dụng readOnly method có chữ kí sau đây:

def readOnly[A](execution: DBSession => A)
  (implicit context: CPContext = NoCPContext): A

Đây là một function mà phải mất 2 đối số, một khối functional block và connection pool đánh dấu ẩn.Bối cảnh của connection pool mặc định được đưa vào phạm vi qua việc nhập scalikejdbc._ , vì vậy chúng ta cần một cách an toàn để bỏ qua các tham số thứ 2, đối với phần thực thi đây là nơi chúng ta sử dụng các tham số DBSession cung cấp cho các truy vấn cơ sở dữ liệu.Cách đơn giản nhất để làm điều đó là sử dụng SQLInterpolation ScalikeJDBC:

val maybeUser = sql"select * from users where user_code = $userCode".
  map(User.fromRS).single().apply()

Có một vài điều quan trọng đáng nói về SQLInterpolation:

  • Mặc dù trông giống như một String, nó lại biến thành JDBC type-safe PreparedStatement s;
  • Việc map method transforms data từ database row tới domain objects chấp nhận function WrappedResultSet => A với A là bất cứ điều gì(có thể là model.User).
  • Single method được sử dụng nếu bạn quan tâm đến tối đa 1 kết quả.Vì nó luôn luôn có khả năng lấy lại một tập kết qủa rỗng, trả về kiểu của single là Option;
  • Apply method thực hiện công việc thực thế và sau đó lấy DBSession như một tham số.

Như để kiểm tra mật khẩu, chúng ta vẫn có thể sử dụng BCrypt.checkpw mà chúng ta đã thảo luận trước đó.Thử áp dụng lí luận này để cài đặt checkUser method:

class AuthService(cacheApi: CacheApi) {
  // ...
  private def checkUser(userCode: String, password: String): Option[User] =
      DB.readOnly { implicit session =>
    val maybeUser = sql"select * from users where user_code = $userCode".
      map(User.fromRS).single().apply()
    maybeUser.flatMap { user =>
      if (BCrypt.checkpw(password, user.password)) {
        Some(user)
      } else None
    }
  }
}

Ở đây chúng ta đang để ẩn session parameter, vì vậy nó sẽ được chuyển đến các apply method tự động. Cuối cùng, là thời điểm để lắp ghép các login method.Như hầu hết các việc đã thực hiện trong helper method, cài đặt nó là khá đơn giản:

class AuthService(cacheApi: CacheApi) {
  // ...
  def login(userCode: String, password: String): Option[Cookie] = {
    for {
      user <- checkUser(userCode, password)
      cookie <- Some(createCookie(user))
    } yield {
      cookie
    }
  }
}

Nói thêm một chút về việc sử dụng kết hợp hai nhiệm vụ tuần tự, nếu người dùng vượt qua các test chúng ta gọi vào phương thức createCookie và trả kết quả.Nếu người dùng fail, chúng ta chỉ cần trả về None và createCookie sẽ không được gọi.

Using Play Forms API

Năm 2016, HTML khong còn hot như 10 năm trước nữa, nhưng nó vẫn có lượng người dùng của nó và vẫn có Play Forms API.Chúng ta sẽ import 2 package trong Application.scala.

import play.api.data.Form
import play.api.data.Forms._

Sau đó chúng ta sẽ cần một class để lưu trữ form data và một Form field để xác nhận việc việ mapping đúng.

case class UserLoginData(username: String, password: String)

class Application(sunService: SunService,
  weatherService: WeatherService,
  actorSystem: ActorSystem) extends Controller {
  
  // ...
  val userDataForm = Form {
    mapping(
      "username" -> nonEmptyText,
      "password" -> nonEmptyText
    )(UserLoginData.apply)(UserLoginData.unapply)
  }
}

Play cung cấp rất nhiều mappings bao gồm text , number , date (maps to java.util.Date ), jodaDate (maps to org.joda.time.DateTime ).Nó cũng chắc chắn rằng types match và constraint là thỏa mãn.Việc cuối cùng là có chấp nhận hay không chấp nhận thôi.Phần cơ bản về Play cho ta thấy rằng: làm thế nào để convert class sang giá trị tuần tự và ngược lại.Từ khi sử dụng UserLoginData class như là tất cả các method được cài đặt tự động và chúng ta chỉ cần reference tới chúng.

Khi mà các business logic đã được cài đặt trong AuthService class, doLogin method sẽ đơn giản như sau:

class Application(sunService: SunService,
  weatherService: WeatherService,
  actorSystem: ActorSystem,
  authService: AuthService) extends Controller {
  
  // ...
  
  def doLogin = Action(parse.anyContent) { implicit request =>
    userDataForm.bindFromRequest.fold(
      formWithErrors => BadRequest,
      userData => {
        val maybeCookie = authService.login(userData.username, userData.password)
        maybeCookie match {
          case Some(cookie) =>
            Redirect("/").withCookies(cookie)
          case None =>
            Ok(views.html.login())
        }
      }
    )
  }
}

Đầu tiên chúng ta thêm AuthService vào list của các tham số contructor.Sau đó, trong doLogin method chúng ta sử dụng bindFromRequest method từ Forms API.Về bindFromRequest method, đúng như tên của nó, nhận request như tham số và cố gắng build UserLoginData object.Nếu User làm sai điều gì đó, ví dụ điền một mật khẩu là một xâu rỗng điều đó là sai, và formWithErrors được sử dụng.Nếu mọi thứ đều OK, chúng ta có thể an tâm với auth logic và có thể redirect user tới homepage, thêm một cookie vào response. Cuối cùng, đừng quên thêm endpoint mới vào routers file:

POST /login controllers.Application.doLogin

Nếu login với user/password hợp lệ, chúng ta sẽ redirect về homepage.Điều quan trọng là hiện tại browser có cookie mới gọi là X-Auth-Token có hiệu lực 10 giờ:

Showing errors on the page

Tại thời điểm này, nếu user cung cấp thông tin auth sai, đơn giản thì login page được refresh.Không có thông báo lỗi, không có popup windows..Nếu user send một form với trường là rỗng kết quả sẽ rất tệ.Trong trường hợp này server sẽ respond với lỗi 400 bad request khiến trình duyệt trở nên trắng xóa.Chúng ta chắc chắn phải làm gì đó tốt hơn thế. Đầu tiên, hãy add thêm parameter mới, gọi là maybeErrorMessage vào login template.Với tên của nó là parameter có thể có bao gồm message lỗi:

@(maybeErrorMessage: Option[String])
<html lang="en">
  <!-- omitted -->
</html

Sau đó bên dưới thẻ legend, chúng ta cần add thêm đoạn code sau:

<!-- omitted -->
<legend>Login</legend>
@maybeErrorMessage.map { errorMessage =>
    <span class="label bg--error">@errorMessage</span>
}
<!-- omitted -->
<p>

Ở đây chúng ta sử dụng @ để start Scala expression.Vì maybeErrorMessage là một Option, chúng ta có thể map nó và span fragment show error sẽ xuất hiện chỉ khi Option tồn tại. Bước tiếp theo là fix Application controller để reflect login template accepts parameter:

class Application(sunService: SunService,
    weatherService: WeatherService,
    actorSystem: ActorSystem,
    authService: AuthService) extends Controller {
  // ...
  def login = Action {
    Ok(views.html.login(None))
  }
  
  def doLogin = Action(parse.anyContent) { implicit request =>
    userDataForm.bindFromRequest.fold(
      formWithErrors => Ok(views.html.login(Some("Wrong data"))),
      userData => {
        // ...
        case None =>
          Ok(views.html.login(Some("Login failed")))
        }
      }
    )
  }
}

Khi user request login page cho lần đầu, form sẽ không bao gồm bất kì lỗi nào, vì thế chúng ta sẽ passing None argument.Trong doLogin, nếu có lỗi phát sinh, chúng ta sẽ gửi lại login page với error message.Login mà không có thông tin nào và bạn sẽ nhìn thấy trang login như sau:

Restricting access

Phần cuối cùng chúng ta sẽ nói tới việc hạn chế truy cập.Để đạt tới mục tiêu của chúng ta, chúng ta sẽ sử dụng một feature của Play là action composition.Feature này cho phép các nhà phát triển tạo ra Action có một số function thêm vào.Đặc biệt, UserAuthenticatedAction của chúng ta sẽ làm như sau:

  • Kiểm tra xem người dùng được xác thực bằng cách kiểm tra X-Auth-Token cookie trong RequestHeader;
  • Redirect unauthenticated users tới login page.
  • Chứng thực người dùng truy cập đến một resource bị hạn chế.
  • Nếu user đã được authenticated, pass User vào trong controller action.

Khi chúng ta đã hiểu chúng ta sẽ kiểm tra cookies ở một số điểm, chúng ta có thể bắt đầu add checkCookie method vào AuthService:

class AuthService(cacheApi: CacheApi) {
  // ...
  def checkCookie(header: RequestHeader): Option[User] = {
    for {
      cookie <- header.cookies.get(cookieHeader)
      user <- cacheApi.get[User](cookie.value)
    } yield {
      user
    }
  }
}

Play nhận thấy nhiều yêu cầu cookie thông qua các cookie field giống như cấu trúc map-like.Nếu cookie có mặt trong bộ sưu tập này, chúng ta sẽ tìm nó ở trong cache và lấy các đối tượng sử dụng có liên quan.Trả về None có nghĩa là User không được authenticated.

ActionBuilder trait ở trong play.api.mvc package là parameter với request type.Điều đó khiến developers có thể sử dụng request type của chính họ thay vì chuẩn của play.api.mvc.Request.Chúng ra sẽ create một file gọi là UserAuthAction ở package services và định nghĩa request wrapper gọi UserAuthRequest:

case class UserAuthRequest[A](user: User, request: Request[A])
  extends WrappedRequest[A](request)

Request wrapper của chúng ta add field mới type User, nhưng trong bất kì cách nào hành vi đều giống như standard Request.Giữ lại class parametrized sẽ cho phép chúng ta sử dụng nó với nội dung HTTP body khác(form, JSON..).

Hãy bắt đầu cài đặt UserAuthAction bằng cách extend ActionBuilder trait:

class UserAuthAction(authService: AuthService)
    extends ActionBuilder[UserAuthRequest] {
  def invokeBlock[A](request: Request[A],
    block: (UserAuthRequest[A]) => Future[Result]): Future[Result] = ???
  }

Như chúng ta đã biết, chúng ta cần AuthService để check cookie, vì thế chúng ta thêm nó như là một constructor parameter.

invokeBlock method chỉ là abstract method định nghĩa trong ActionBuilder.Lấy tín hiệu ở trước nó.Parameter đầu tiên đại diện cho original request nhận được từ server.Parameter thứ 2 là một function từ UserAuthRequest to Future[Result].Nó là cái gì nhỉ?Nó là block chúng ta viết khi chúng ta định nghĩa controller action mới.Điều này cho thấy làm thế nào để chúng ta có thể giải quyết được việc thực hiện các invokeBlock method:

  1. Pass RequestHeader to the checkCookie method của AuthService;
  2. Nếu check fails, trả về với redirecting user tới login page.
  3. Nếu check succeeds, build một instance mới của UserAuthRequest sử dụng Request chính và User object thu được từ checkCookie.
  4. Kỳ vọng block đó passing instance của UserAuthRequest như một argument.
  5. Bất kì block nào cũng đều trở thành kết quả của invokeBlock method.

Chúng ta sẽ chuyển những ý tưởng trên thành đoạn cài đặt sau đây:

class UserAuthAction(authService: AuthService)
    extends ActionBuilder[UserAuthRequest] {
    
  def keBlock[A](request: Request[A],
        block: (UserAuthRequest[A]) => Future[Result]): Future[Result] = {
    val maybeUser = authService.checkCookie(request)
    maybeUser match {
      case None => Future.successful(Results.Redirect("/login"))
      case Some(user) => block(UserAuthRequest(user, request))
    }
  }
}

Lưu ý rằng chúng ta passing Request vào checkCookie method vì trong Play API RequestHeader là một supertype của Request.

Để thử nghiệm UserAuthAction chúng ta sẽ cần một template mới lấy một User object như là một parameter, vì vậy hãy tạo một file mới gọi là restricted.scala.html với nội dung dưới đây:

@(user: model.User)
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Restricted</title>
        <link rel="stylesheet"
          href="@routes.Assets.versioned("compiled/styles.css")">
        <link rel="shortcut icon" type="image/png"
          href="@routes.Assets.versioned("images/favicon.png")">
    </head>
    <body>
        <h1>Hello @user.userCode</h1>
        <div>Your id is @user.userId</div>
    </body>
</html>

Chúng ta sẽ thêm UserAuthenticate như là constructor parameter vào Application controller và sau đó chúng ta sẽ có thể sử dụng nó như là một Action bình thường:

class Application(sunService: SunService,
    weatherService: WeatherService,
    actorSystem: ActorSystem,
    authService: AuthService,
    userAuthAction: UserAuthAction) extends Controller {
  def restricted = userAuthAction { userAuthRequest =>
    Ok(views.html.restricted(userAuthRequest.user))
  }
  // ...
}

Sự khác biệt quan trọng đó là thay vì standard Request chúng ta có một UserAuthRequest, với user field đã khởi tạo.Để pass được UserAuthAction instance vào Application controller chúng ta cần khởi tạo nó trong AppComponents trait:

trait AppComponents extends BuiltInComponents with AhcWSComponents
  with EvolutionsComponents with DBComponents with HikariCPComponents
  with EhCacheComponents {
  
  // ...
    lazy val userAuthAction = wire[UserAuthAction]
}

Một lần nữa wire macro qua tâm tới việc passing AuthService vào UserAuthAction constructor.Một điều cần phải làm nữa là add restricted route:

GET /restricted controllers.Application.restricted

Hãy thử truy cập vào địa chỉ localhost:9000/restricted bạn sẽ login tới login page.Tuy nhiên, nếu bạn đi tới restricted page sau khi điền chính xác thông tin bạn sẽ nhìn thấy thông tin của mình

Nguồn: dịch từ cuốn "Modern Web Development with Scala"