Skip to content

Commit

Permalink
Harden the login endpoints against cross site request forgery
Browse files Browse the repository at this point in the history
  • Loading branch information
mattesmohr committed Sep 14, 2024
1 parent f3a6ee4 commit f72666a
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 12 deletions.
72 changes: 66 additions & 6 deletions Sources/Website/Controllers/Areas/LoginAreaController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ struct LoginAreaController {
@Sendable
func getLogin(_ request: Request) async throws -> View {

// Create form token and store it to verify it in the post request
request.application.htmlkit.environment.upsert(Nonce(), for: \Nonce.self)

let viewModel = LoginAreaPageModel.LoginViewModel()

return try await request.htmlkit.render(LoginAreaPage.LoginView(viewModel: viewModel))
Expand All @@ -27,6 +30,12 @@ struct LoginAreaController {

let login = try request.content.decode(LoginModel.Input.self)

guard let nonce = request.application.htmlkit.environment.retrieve(for: \Nonce.self) as? Nonce else {
throw Abort(.internalServerError)
}

try nonce.verify(nonce: login.nonce)

guard let user = try await UserRepository(database: request.db)
.find(email: login.email) else {
return request.redirect(to: "/area/login/login")
Expand Down Expand Up @@ -94,21 +103,45 @@ struct LoginAreaController {
// [:id/register]
@Sendable
func getRegister(_ request: Request) async throws -> View {

guard let id = request.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}

guard let user = try await UserRepository(database: request.db)
.find(id: id) else {
throw Abort(.notFound)
}

if let _ = user.credential {
// The user is already registered, therefore abort the request
throw Abort(.badRequest)
}

// Create form token and store it to verify it in the post request
request.application.htmlkit.environment.upsert(Nonce(), for: \Nonce.self)

return try await request.htmlkit.render(LoginAreaPage.RegisterView())
}

// [:id/register/:model]
@Sendable
func postRegister(_ request: Request) async throws -> Response {

guard let id = request.parameters.get("user", as: UUID.self) else {
guard let id = request.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}

try ResetModel.Input.validate(content: request)

let reset = try request.content.decode(ResetModel.Input.self)

guard let nonce = request.application.htmlkit.environment.retrieve(for: \Nonce.self) as? Nonce else {
throw Abort(.internalServerError)
}

try nonce.verify(nonce: reset.nonce)

let digest = try await request.password.async.hash(reset.password)

try await CredentialRepository(database: request.db)
Expand All @@ -120,21 +153,48 @@ struct LoginAreaController {
// [:id/reset]
@Sendable
func getReset(_ request: Request) async throws -> View {

guard let id = request.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}

guard let user = try await UserRepository(database: request.db)
.find(id: id) else {
throw Abort(.notFound)
}

if let credential = user.credential {

if credential.status != "reseted" {
// The reset was not initiated, therefore abort the request
throw Abort(.badRequest)
}
}

// Create form token and store it to verify it in the post request
request.application.htmlkit.environment.upsert(Nonce(), for: \Nonce.self)

return try await request.htmlkit.render(LoginAreaPage.ResetView())
}

// [:id/reset/:model]
@Sendable
func postReset(_ request: Request) async throws -> Response {

guard let id = request.parameters.get("user", as: UUID.self) else {
guard let id = request.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}

try ResetModel.Input.validate(content: request)

let reset = try request.content.decode(ResetModel.Input.self)

guard let nonce = request.application.htmlkit.environment.retrieve(for: \Nonce.self) as? Nonce else {
throw Abort(.internalServerError)
}

try nonce.verify(nonce: reset.nonce)

guard let user = try await UserRepository(database: request.db)
.find(id: id) else {
throw Abort(.notFound)
Expand Down Expand Up @@ -163,10 +223,10 @@ extension LoginAreaController: RouteCollection {
routes.get("login", use: self.getLogin)
routes.post("login", use: self.postLogin)
routes.get("logout", use: self.getLogout)
routes.get(":user", "register", use: self.getRegister)
routes.post(":user", "register", use: self.postRegister)
routes.get(":user", "reset", use: self.getReset)
routes.post(":user", "reset", use: self.postReset)
routes.get(":id", "register", use: self.getRegister)
routes.post(":id", "register", use: self.postRegister)
routes.get(":id", "reset", use: self.getReset)
routes.post(":id", "reset", use: self.postReset)
}
}
}
22 changes: 22 additions & 0 deletions Sources/Website/Metas/Nonce.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import Vapor

/// A unique token to verify its form origin
struct Nonce {

/// The token value
let value: String

/// Initializes the nonce
init() {
self.value = [UInt8].random(count: 32).base64
}

/// Verifies the form token with the global token
func verify(nonce: String) throws {

if self.value != nonce {
throw Abort(.badRequest)
}
}
}
3 changes: 3 additions & 0 deletions Sources/Website/Models/LoginModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ struct LoginModel {
/// The plaintext password for the login
var password: String

/// A unique token to verify the forms origin
let nonce: String

static func validations(_ validations: inout Validations) {

validations.add("email", as: String.self, is: .email)
Expand Down
3 changes: 3 additions & 0 deletions Sources/Website/Models/ResetModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ struct ResetModel: Content {
/// The password confirmation of the password
var confirmation: String

/// A unique token to verify the forms origin
var nonce: String

/// Validate the input
static func validations(_ validations: inout Validations) {

Expand Down
25 changes: 19 additions & 6 deletions Sources/Website/Views/Areas/LoginArePage/LoginAreaPage+Form.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ extension LoginAreaPage {

struct LoginForm: View {

@EnvironmentObject(Nonce.self)
var nonce

var body: Content {
Form(method: .post) {
VStack {
Expand All @@ -22,13 +25,15 @@ extension LoginAreaPage {
.borderShape(.smallrounded)
}
.margin(insets: .bottom, length: .small)
HStack {
Button(role: .submit) {
"Sign in"
}
.buttonStyle(PrimaryButton())
.controlSize(.full)
HTMLKit.Input()
.type(.hidden)
.name("nonce")
.custom(key: "value", value: nonce.value)
Button(role: .submit) {
"Sign in"
}
.buttonStyle(PrimaryButton())
.controlSize(.full)
.margin(insets: .bottom, length: .small)
}
.tag("login-form")
Expand All @@ -40,6 +45,9 @@ extension LoginAreaPage {

struct ResetForm: View {

@EnvironmentObject(Nonce.self)
var nonce

var body: Content {
Form(method: .post) {
VStack {
Expand All @@ -57,11 +65,16 @@ extension LoginAreaPage {
.borderShape(.smallrounded)
}
.margin(insets: .bottom, length: .small)
HTMLKit.Input()
.type(.hidden)
.name("nonce")
.custom(key: "value", value: nonce.value)
Button(role: .submit) {
"Reset"
}
.buttonStyle(PrimaryButton())
.controlSize(.full)
.margin(insets: .bottom, length: .small)
}
.tag("reset-form")
.onSubmit { form in
Expand Down

0 comments on commit f72666a

Please sign in to comment.