Skip to content

Commit

Permalink
Add default implementation for CredentialsAuthenticatable (#711)
Browse files Browse the repository at this point in the history
* Remove warning in tests

* Add ModelCredentialsAuthenticatable

* Add failing test for credentials stuff

* Use the correct user in tests

* Get the tests passing

* Use provided Database ID for sessions authenticator

* Refactor test DB name
  • Loading branch information
0xTim committed Dec 8, 2020
1 parent eae4082 commit 855cd81
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 2 deletions.
2 changes: 1 addition & 1 deletion Sources/Fluent/Fluent+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ private struct DatabaseSessionAuthenticator<User>: SessionAuthenticator
let databaseID: DatabaseID?

func authenticate(sessionID: User.SessionID, for request: Request) -> EventLoopFuture<Void> {
User.find(sessionID, on: request.db).map {
User.find(sessionID, on: request.db(self.databaseID)).map {
if let user = $0 {
request.auth.login(user)
}
Expand Down
54 changes: 54 additions & 0 deletions Sources/Fluent/ModelCredentialsAuthenticatable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Vapor

public protocol ModelCredentialsAuthenticatable: Model, Authenticatable {
static var usernameKey: KeyPath<Self, Field<String>> { get }
static var passwordHashKey: KeyPath<Self, Field<String>> { get }
func verify(password: String) throws -> Bool
}

extension ModelCredentialsAuthenticatable {
public static func credentialsAuthenticator(
database: DatabaseID? = nil
) -> Authenticator {
ModelCredentialsAuthenticator<Self>(database: database)
}

var _$username: Field<String> {
self[keyPath: Self.usernameKey]
}

var _$passwordHash: Field<String> {
self[keyPath: Self.passwordHashKey]
}
}

public struct ModelCredentials: Content {
public let username: String
public let password: String

public init(username: String, password: String) {
self.username = username
self.password = password
}
}

private struct ModelCredentialsAuthenticator<User>: CredentialsAuthenticator
where User: ModelCredentialsAuthenticatable
{
typealias Credentials = ModelCredentials

public let database: DatabaseID?

func authenticate(credentials: ModelCredentials, for request: Request) -> EventLoopFuture<Void> {
User.query(on: request.db(self.database)).filter(\._$username == credentials.username).first().flatMapThrowing { foundUser in
guard let user = foundUser else {
return
}
guard try user.verify(password: credentials.password) else {
return
}
request.auth.login(user)
}
}
}

98 changes: 98 additions & 0 deletions Tests/FluentTests/CredentialTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import XCTFluent
import XCTVapor
import Fluent
import Vapor

final class CredentialTests: XCTestCase {

func testCredentialsAuthentication() throws {
let app = Application(.testing)
defer { app.shutdown() }

// Setup test db.
let testDB = ArrayTestDatabase()
app.databases.use(testDB.configuration, as: .test)

// Configure sessions.
app.middleware.use(app.sessions.middleware)

// Setup routes.
let sessionRoutes = app.grouped(CredentialsUser.sessionAuthenticator())

let credentialRoutes = sessionRoutes.grouped(CredentialsUser.credentialsAuthenticator())
credentialRoutes.post("login") { req -> Response in
guard req.auth.has(CredentialsUser.self) else {
throw Abort(.unauthorized)
}
return req.redirect(to: "/protected")
}

let protectedRoutes = sessionRoutes.grouped(CredentialsUser.redirectMiddleware(path: "/login"))
protectedRoutes.get("protected") { req -> HTTPStatus in
_ = try req.auth.require(CredentialsUser.self)
return .ok
}

// Create user
let password = "password-\(Int.random())"
let passwordHash = try Bcrypt.hash(password)
let testUser = CredentialsUser(id: UUID(), username: "user-\(Int.random())", password: passwordHash)
testDB.append([TestOutput(testUser)])
testDB.append([TestOutput(testUser)])
testDB.append([TestOutput(testUser)])
testDB.append([TestOutput(testUser)])

// Test login
let loginData = ModelCredentials(username: testUser.username, password: password)
try app.test(.POST, "/login", beforeRequest: { req in
try req.content.encode(loginData, as: .urlEncodedForm)
}) { res in
XCTAssertEqual(res.status, .seeOther)
XCTAssertEqual(res.headers[.location].first, "/protected")
let sessionID = try XCTUnwrap(res.headers.setCookie?["vapor-session"]?.string)

// Test accessing protected route
try app.test(.GET, "/protected", beforeRequest: { req in
var cookies = HTTPCookies()
cookies["vapor-session"] = .init(string: sessionID)
req.headers.cookie = cookies
}) { res in
XCTAssertEqual(res.status, .ok)
}
}


}
}

final class CredentialsUser: Model {
static let schema = "users"

@ID(key: .id)
var id: UUID?

@Field(key: "username")
var username: String

@Field(key: "password")
var password: String

init() { }

init(id: UUID? = nil, username: String, password: String) {
self.id = id
self.username = username
self.password = password
}
}


extension CredentialsUser: ModelCredentialsAuthenticatable {
static let usernameKey = \CredentialsUser.$username
static let passwordHashKey = \CredentialsUser.$password

func verify(password: String) throws -> Bool {
try Bcrypt.verify(password, created: self.password)
}
}
extension CredentialsUser: ModelSessionAuthenticatable {}
2 changes: 1 addition & 1 deletion Tests/FluentTests/SessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ final class SessionTests: XCTestCase {
TestOutput([
"id": UUID(),
"key": SessionID(string: sessionID!),
"data": SessionData(["name": "vapor"])
"data": SessionData(initialData: ["name": "vapor"])
])
])
// Add empty query output for session update.
Expand Down

0 comments on commit 855cd81

Please sign in to comment.