Vapor Swift - Bearer Authentication
How to setup a bearer authent with Vapor Swift
Setting Up Models
To get started, we will define two models: User as a Fluent Model and Token, which represents a bearer token. The User model will handle user credentials, while the Token model will manage authentication tokens.
Defining the User Model
The User model represents a user in our application. It conforms to Model for database interactions and Content for Codable support.
import Vapor
import Fluent
final class User: Model, Content, @unchecked Sendable {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "passwordHash")
var passwordHash: String
init() {}
init(id: UUID? = nil, username: String, passwordHash: String) {
self.id = id
self.username = username
self.passwordHash = passwordHash
}
}
[!NOTE] The
@unchecked Sendable
attribute is necessary for compatibility with Swift 6. For more details, see the Vapor blog post on Sendable.
Making User Conform to ModelAuthenticatable
To enable user authentication, we need to implement ModelAuthenticatable.
extension User: ModelAuthenticatable {
static let usernameKey = \User.$username
static let passwordHashKey = \User.$passwordHash
func verify(password: String) throws -> Bool {
try Bcrypt.verify(password, created: self.passwordHash)
}
}
Defining the Token Model
The Token model will manage authentication tokens for users, allowing them to authenticate using bearer tokens.
import Fluent
import Vapor
final class Token: Model, Content, @unchecked Sendable {
static let schema = "tokens"
@ID(key: .id)
var id: UUID?
@Field(key: "value")
var value: String
@Parent(key: "userID")
var user: User
@Field(key: "expiresAt")
var expiresAt: Date?
init() {}
init(id: UUID? = nil, value: String, userID: UUID, expiresAt: Date? = nil) {
self.id = id
self.value = value
self.$user.id = userID
self.expiresAt = expiresAt
}
}
Adding Migrations
To persist these models in the database, create migrations for both User and Token.
User Migration
import Fluent
extension User {
struct Migration: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema(User.schema)
.id()
.field("username", .string, .required)
.field("passwordHash", .string, .required)
.unique(on: "username")
.create()
}
func revert(on database: Database) async throws {
try await database.schema(User.schema).delete()
}
}
}
Token Migration
extension Token {
struct Migration: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema(Token.schema)
.id()
.field("value", .string, .required)
.field("userID", .uuid, .required, .references(User.schema, .id))
.field("expiresAt", .datetime)
.unique(on: "value")
.create()
}
func revert(on database: Database) async throws {
try await database.schema(Token.schema).delete()
}
}
}
Registering Migrations
Add the migrations to your application configuration:
public func configure(_ app: Application) throws {
// Register migrations
app.migrations.add(User.Migration())
app.migrations.add(Token.Migration())
// Additional setup...
}
Authentication Controller
The AuthController handles user authentication and token generation.
import Fluent
import Vapor
struct AuthController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let auth = routes.grouped("auth")
auth.post("login", use: login)
}
@Sendable
func login(req: Request) async throws -> TokenResponse {
let loginData = try req.content.decode(UserDataRequest.self)
// Find user by username
guard let user = try await User.query(on: req.db)
.filter(\.$username == loginData.username)
.first()
else {
throw Abort(.unauthorized, reason: "Invalid username or password.")
}
// Verify password
guard try user.verify(password: loginData.password) else {
throw Abort(.unauthorized, reason: "Invalid username or password.")
}
// Generate and store token
let tokenString = UUID().uuidString
let token = Token(value: tokenString, userID: try user.requireID())
try await token.save(on: req.db)
return TokenResponse(token: tokenString)
}
}
struct TokenResponse: Content {
let token: String
}
struct UserDataRequest: Content {
let username: String
let password: String
}
Signup route to register users
The signup route allows new users to register by providing a username and password. Here’s how the implementation works:
struct AuthController: RouteCollection {
// ...
@Sendable
func signup(req: Request) async throws -> HTTPStatus {
// Decode signup request
let signupData = try req.content.decode(UserDataRequest.self)
// Check if username already exists
let existingUser = try await User.query(on: req.db)
.filter(\.$username == signupData.username)
.first()
guard existingUser == nil else {
throw Abort(.conflict, reason: "Username already exists")
}
// Create new user with hashed password
let passwordHash = try Bcrypt.hash(signupData.password)
let user = User(username: signupData.username, passwordHash: passwordHash)
// Save user to database
try await user.save(on: req.db)
// Return successful response
return .created
}
}
Bearer Authentication Middleware
The middleware verifies the provided token in the Authorization header.
struct AuthController: RouteCollection {
// Other methods...
@Sendable
func signup(req: Request) async throws -> HTTPStatus {
// Decode the signup request payload
let signupData = try req.content.decode(UserDataRequest.self)
// Validate the request data (e.g., non-empty username and password)
guard !signupData.username.isEmpty, !signupData.password.isEmpty else {
throw Abort(.badRequest, reason: "Username and password must not be empty.")
}
// Check if the username already exists in the database
let existingUser = try await User.query(on: req.db)
.filter(\.$username == signupData.username)
.first()
guard existingUser == nil else {
throw Abort(.conflict, reason: "Username already exists.")
}
// Hash the password before storing it
let passwordHash = try Bcrypt.hash(signupData.password)
let user = User(username: signupData.username, passwordHash: passwordHash)
// Save the new user to the database
try await user.save(on: req.db)
// Respond with a 201 Created status
return .created
}
}
Protecting Endpoints
Use the BearerAuthenticator middleware to protect routes that require authentication.
func routes(_ app: Application) throws {
let authController = AuthController()
try app.register(collection: authController)
let protected = app.grouped(BearerAuthenticator())
protected.get("profile", use: userProfile)
}
func userProfile(req: Request) async throws -> User.Public {
let user = try req.auth.require(User.self)
return user.convertToPublic()
}
extension User {
struct Public: Content {
let id: UUID?
let username: String
}
func convertToPublic() -> Public {
Public(id: self.id, username: self.username)
}
}
Testing the Application
Here’s how to test the /auth/signup
and /auth/login
endpoints, as well as protected routes like /profile
.
Create a User
Send a POST request to /auth/signup
with the following payload:
curl --location 'localhost:8080/auth/signup' \
--data '{
"username": "admin",
"password": "p@ssw0rd"
}'
Expected Response
Status Code: 201 Created
Obtain a Token
Send a POST request to /auth/login
with the following JSON payload:
curl --location 'localhost:8080/auth/login' \
--data '{
"username": "admin",
"password": "p@ssw0rd"
}'
Expected Response
- Status Code: 200 OK
- Body:
{
"token": "generated_token"
}
Access a Protected Route
Use the token from the previous step to access a protected route, such as /profile
. Send a GET request with the Authorization header:
curl --location 'localhost:8080/profile' \
--header 'Authorization: Bearer •••••••••••••••••••••'
Expected Response
- Status Code: 200 OK
- Body:
{
"id": "FA7816E9-F837-4C36-B2DC-1F002BDA209C",
"username": "admin2"
}
Additional Notes for Testing:
- Validation Errors: Test scenarios where invalid or incomplete data is submitted (e.g., missing username or password).
- Conflict Handling: Attempt to register a user with a username that already exists to verify the 409 Conflict response.
- Token Expiration: If token expiration is implemented, ensure expired tokens are rejected when accessing protected routes.
These steps ensure that both registration and authentication flows are working as expected.
Enhancements
- Password Security: Always hash passwords using a strong algorithm like Bcrypt.
- Token Management: Use JWT for more scalable authentication systems.
- Token Expiration: Ensure tokens have a clear expiry policy.
- Error Handling: Expand error handling and input validation to meet your application’s needs.