├── .dockerignore ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── Public └── .gitkeep ├── README.md ├── Sources ├── App │ ├── AppConfig.swift │ ├── Authenticators │ │ └── UserAuthenticator.swift │ ├── Constants.swift │ ├── Controllers │ │ ├── .gitkeep │ │ └── AuthenticationController.swift │ ├── Emails │ │ ├── Email.swift │ │ ├── ResetPasswordEmail.swift │ │ └── VerificationEmail.swift │ ├── Errors │ │ ├── AppError.swift │ │ └── AuthenticationError.swift │ ├── Extensions │ │ ├── Data+Base64URL.swift │ │ ├── Mailgun+Domains.swift │ │ ├── QueueContext+Services.swift │ │ ├── Request+Services.swift │ │ ├── SHA256+Base64.swift │ │ └── SHA256+String.swift │ ├── Jobs │ │ └── EmailJob.swift │ ├── Middleware │ │ └── ErrorMiddleware.swift │ ├── Migrations │ │ ├── CreateEmailToken.swift │ │ ├── CreatePasswordToken.swift │ │ ├── CreateRefreshToken.swift │ │ └── CreateUser.swift │ ├── Models │ │ ├── .gitkeep │ │ ├── DTO │ │ │ ├── Authentication │ │ │ │ ├── AccessToken │ │ │ │ │ ├── AccessTokenRequest.swift │ │ │ │ │ └── AccessTokenResponse.swift │ │ │ │ ├── EmailVerification │ │ │ │ │ └── SendEmailVerificationRequest.swift │ │ │ │ ├── Login │ │ │ │ │ ├── LoginRequest.swift │ │ │ │ │ └── LoginResponse.swift │ │ │ │ ├── Register │ │ │ │ │ └── RegisterRequest.swift │ │ │ │ └── ResetPassword │ │ │ │ │ ├── RecoverAccountRequest.swift │ │ │ │ │ └── ResetPasswordRequest.swift │ │ │ └── Users │ │ │ │ └── UserDTO.swift │ │ ├── Entities │ │ │ ├── EmailToken.swift │ │ │ ├── PasswordToken.swift │ │ │ ├── RefreshToken.swift │ │ │ └── User.swift │ │ └── JWT │ │ │ └── Payload.swift │ ├── Repositories │ │ ├── EmailTokenRepository.swift │ │ ├── PasswordTokenRepository.swift │ │ ├── RefreshTokenRepository.swift │ │ └── UserRepository.swift │ ├── Services │ │ ├── EmailVerifier.swift │ │ ├── PasswordResetter.swift │ │ ├── RandomGenerator │ │ │ ├── Application+RandomGenerator.swift │ │ │ ├── Application+RandomGenerators.swift │ │ │ ├── RealRandomGenerator.swift │ │ │ └── Request+RandomGenerator.swift │ │ ├── Repositories.swift │ │ └── RequestService.swift │ ├── configure.swift │ ├── migrations.swift │ ├── queues.swift │ ├── routes.swift │ └── services.swift └── Run │ └── main.swift ├── Tests ├── .gitkeep └── AppTests │ ├── AuthenticationTests │ ├── AuthenticationTests.swift │ ├── EmailVerificationTests.swift │ ├── LoginTests.swift │ ├── RegisterTests.swift │ ├── ResetPasswordTests.swift │ └── TokenTests.swift │ ├── Helpers │ ├── Application+Helpers.swift │ ├── TestRepository.swift │ └── XCTAssertResponseError.swift │ ├── Mocks │ ├── Mailgun+Mock.swift │ ├── Repositories │ │ ├── TestEmailTokenRepository.swift │ │ ├── TestPasswordTokenRepository.swift │ │ ├── TestRefreshTokenRepository.swift │ │ └── TestUserRepository.swift │ └── RiggedRandomGenerator.swift │ ├── RepositoryTests │ ├── EmailTokenRepostitoryTests.swift │ ├── PasswordTokenRepositoryTests.swift │ ├── RefreshTokenRepositoryTests.swift │ └── UserRepositoryTests.swift │ ├── ServiceTests │ └── RandomGeneratorTests.swift │ └── TestWorld.swift ├── docker-compose.yml └── web.Dockerfile /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .build 3 | DerivedData 4 | *.xcodeproj 5 | .swiftpm 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - pull_request 4 | env: 5 | MAILGUN_API_KEY: test 6 | SITE_API_URL: test 7 | SITE_FRONTEND_URL: test 8 | NO_REPLY_EMAIL: test 9 | POSTGRES_HOSTNAME: postgres 10 | jobs: 11 | xenial: 12 | container: 13 | image: vapor/swift:5.2-xenial 14 | runs-on: ubuntu-latest 15 | services: 16 | postgres: 17 | image: postgres 18 | env: 19 | POSTGRES_USER: vapor 20 | POSTGRES_DB: vapor 21 | POSTGRES_PASSWORD: password 22 | steps: 23 | - uses: actions/checkout@v1 24 | - run: swift test --enable-test-discovery --sanitize=thread 25 | bionic: 26 | container: 27 | image: vapor/swift:5.2-bionic 28 | runs-on: ubuntu-latest 29 | services: 30 | postgres: 31 | image: postgres 32 | env: 33 | POSTGRES_USER: vapor 34 | POSTGRES_DB: vapor 35 | POSTGRES_PASSWORD: password 36 | steps: 37 | - uses: actions/checkout@v1 38 | - run: swift test --enable-test-discovery --sanitize=thread 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | DerivedData/ 6 | .DS_Store 7 | db.sqlite 8 | .swiftpm 9 | .env 10 | *.jwks 11 | Package.resolved 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mads Odgaard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "vapor-auth-template", 6 | platforms: [ 7 | .macOS(.v10_15), 8 | ], 9 | products: [ 10 | .executable(name: "Run", targets: ["Run"]), 11 | .library(name: "App", targets: ["App"]), 12 | ], 13 | dependencies: [ 14 | // 💧 A server-side Swift web framework. 15 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 16 | .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), 17 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), 18 | .package(url: "https://github.com/vapor/jwt.git", from: "4.0.0-rc.2"), 19 | .package(url: "https://github.com/vapor/queues.git", from: "1.0.0"), 20 | .package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.0-rc.1"), 21 | 22 | // Mailgun 23 | .package(url: "https://github.com/vapor-community/mailgun.git", from: "5.0.0") 24 | ], 25 | targets: [ 26 | .target(name: "App", dependencies: [ 27 | .product(name: "Fluent", package: "fluent"), 28 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 29 | .product(name: "Vapor", package: "vapor"), 30 | .product(name: "JWT", package: "jwt"), 31 | .product(name: "QueuesRedisDriver", package: "queues-redis-driver"), 32 | .product(name: "Mailgun", package: "mailgun") 33 | ]), 34 | .target(name: "Run", dependencies: [ 35 | .target(name: "App"), 36 | ]), 37 | .testTarget(name: "AppTests", dependencies: [ 38 | .target(name: "App"), 39 | .product(name: "XCTVapor", package: "vapor"), 40 | .product(name: "XCTQueues", package: "queues") 41 | ]) 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /Public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsodgaard/vapor-auth-template/fc3f4a7d8a6c3f74305c38718f0798728d46fda1/Public/.gitkeep -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vapor Authentication Template 2 | [![Swift 5.2](https://img.shields.io/badge/swift-5.2-orange.svg?style=flat)](http://swift.org) 3 | [![Vapor 4](https://img.shields.io/badge/vapor-4.0-blue.svg?style=flat)](https://vapor.codes) 4 | 5 | This package is a Vapor 4 template to showcase different features and include authentication functions needed for a lot of apps. It uses concepts such as: repository pattern, queues, jwt, fluent, testing and mailgun 6 | 7 | The template can be cloned and configured/changed to fit your needs, but should give a good starting point to anyone new to Vapor. 8 | 9 | ## Features 10 | * User registration 11 | * User login 12 | * Reset password 13 | * Email verification 14 | * Refresh and access tokens 15 | * Testing 16 | * JWT Authentication 17 | * Queues for email sending 18 | * Repository Pattern 19 | * Mailgun 20 | 21 | ## Routes 22 | | URL | HTTP Method | Description | Content (Body) | 23 | |---------------------------------|:-----------:|----------------------------------------------------------|-------------------------| 24 | | /api/auth/register | POST | Registers a user and sends email verification | `RegisterRequest` | 25 | | /api/auth/login | POST | Login with existing user (requires email verification) | `LoginRequest` | 26 | | /api/auth/email-verification | GET | Used to verify an email with a email verification token | Query parameter `token` | 27 | | /api/auth/email-verification | POST | (Re)sends email verification to a specific email | `SendEmailVerification` | 28 | | /api/auth/reset-password | POST | Sends reset-password email with token | `ResetPasswordRequest` | 29 | | /api/auth/reset-password/verify | GET | Verifies a given reset-password token | Query parameter `token` | 30 | | /api/auth/recover | POST | Changes user password with reset-password token supplied | `RecoverAccountRequest` | 31 | | /api/auth/me | GET | Returns the current authenticated user | None | 32 | | /api/auth/accessToken | POST | Gives the user a new accesstoken and refresh token | `AccessTokenRequest` | 33 | 34 | ## Configuration 35 | ### Environment variables 36 | These environment variables will be used for configuring different services by default: 37 | | Key | Default Value | Description | 38 | |---------------------|--------------------------|-----------------------------------------------------------------------------------------------------| 39 | | `POSTGRES_HOSTNAME` | `localhost` | Postgres hostname | 40 | | `POSTGRES_USERNAME` | `vapor` | Postgres usernane | 41 | | `POSTGRES_PASSWORD` | `password` | Postgres password | 42 | | `POSTGRES_DATABASE` | `vapor` | Postgres database | 43 | | `JWKS_KEYPAIR_FILE` | `keypair.jwks` | JWKS Keypair file relative to root directory see "JWT" section for more info | 44 | | `MAILGUN_API_KEY` | None | Mailgun API Key | 45 | | `SITE_API_URL` | None | The URL where your API will be hosted ex: "https://api.myapp.com" (used for email-verification URL) | 46 | | `SITE_FRONTEND_URL` | None | The URL where your frontend will be hosted ex: "http://myapp.com" (used for reset-password URL) | 47 | | `NO_REPLY_EMAIL` | None | The no reply email that will be used for Mailgun | 48 | | `REDIS_URL` | `redis://127.0.0.1:6379` | Redis URL for Queues worker. | 49 | ### App config 50 | `AppConfig` contains configuration like API URL, frontend URL and no-reply email. It loads from environment variables by default. Otherwise you can override it inside `configure.swift`: 51 | ```swift 52 | app.config = .init(...) 53 | ``` 54 | 55 | ### Constants 56 | `Constants.swift` contains constants releated to tokens lifetime. 57 | | Token | Lifetime | 58 | |--------------------------|------------| 59 | | Access Token | 15 minutes | 60 | | Refresh Token | 7 days | 61 | | Email Verification Token | 24 hours | 62 | | Reset Password Token | 1 hour | 63 | 64 | ### Mailgun 65 | The template uses [VaporMailgunService](https://github.com/vapor-community/VaporMailgunService) and be configured as it states in the documentation. `Extensions/Mailgun+Domains.swift` contains the domains. 66 | 67 | ### JWT 68 | This package uses JWT for Access Tokens, and by default it loads JWT credentials from a JWKS file called `keypair.jwks` in the root directory. You can generate a JWKS keypair at https://mkjwk.org/ 69 | -------------------------------------------------------------------------------- /Sources/App/AppConfig.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct AppConfig { 4 | let frontendURL: String 5 | let apiURL: String 6 | let noReplyEmail: String 7 | 8 | static var environment: AppConfig { 9 | guard 10 | let frontendURL = Environment.get("SITE_FRONTEND_URL"), 11 | let apiURL = Environment.get("SITE_API_URL"), 12 | let noReplyEmail = Environment.get("NO_REPLY_EMAIL") 13 | else { 14 | fatalError("Please add app configuration to environment variables") 15 | } 16 | 17 | return .init(frontendURL: frontendURL, apiURL: apiURL, noReplyEmail: noReplyEmail) 18 | } 19 | } 20 | 21 | extension Application { 22 | struct AppConfigKey: StorageKey { 23 | typealias Value = AppConfig 24 | } 25 | 26 | var config: AppConfig { 27 | get { 28 | storage[AppConfigKey.self] ?? .environment 29 | } 30 | set { 31 | storage[AppConfigKey.self] = newValue 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/App/Authenticators/UserAuthenticator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import JWT 3 | 4 | struct UserAuthenticator: JWTAuthenticator { 5 | typealias Payload = App.Payload 6 | 7 | func authenticate(jwt: Payload, for request: Request) -> EventLoopFuture { 8 | request.auth.login(jwt) 9 | return request.eventLoop.makeSucceededFuture(()) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/App/Constants.swift: -------------------------------------------------------------------------------- 1 | struct Constants { 2 | /// How long should access tokens live for. Default: 15 minutes (in seconds) 3 | static let ACCESS_TOKEN_LIFETIME: Double = 60 * 15 4 | /// How long should refresh tokens live for: Default: 7 days (in seconds) 5 | static let REFRESH_TOKEN_LIFETIME: Double = 60 * 60 * 24 * 7 6 | /// How long should the email tokens live for: Default 24 hours (in seconds) 7 | static let EMAIL_TOKEN_LIFETIME: Double = 60 * 60 * 24 8 | /// Lifetime of reset password tokens: Default 1 hour (seconds) 9 | static let RESET_PASSWORD_TOKEN_LIFETIME: Double = 60 * 60 10 | } 11 | -------------------------------------------------------------------------------- /Sources/App/Controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsodgaard/vapor-auth-template/fc3f4a7d8a6c3f74305c38718f0798728d46fda1/Sources/App/Controllers/.gitkeep -------------------------------------------------------------------------------- /Sources/App/Controllers/AuthenticationController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | struct AuthenticationController: RouteCollection { 5 | func boot(routes: RoutesBuilder) throws { 6 | routes.group("auth") { auth in 7 | auth.post("register", use: register) 8 | auth.post("login", use: login) 9 | 10 | auth.group("email-verification") { emailVerificationRoutes in 11 | emailVerificationRoutes.post("", use: sendEmailVerification) 12 | emailVerificationRoutes.get("", use: verifyEmail) 13 | } 14 | 15 | auth.group("reset-password") { resetPasswordRoutes in 16 | resetPasswordRoutes.post("", use: resetPassword) 17 | resetPasswordRoutes.get("verify", use: verifyResetPasswordToken) 18 | } 19 | auth.post("recover", use: recoverAccount) 20 | 21 | auth.post("accessToken", use: refreshAccessToken) 22 | 23 | // Authentication required 24 | auth.group(UserAuthenticator()) { authenticated in 25 | authenticated.get("me", use: getCurrentUser) 26 | } 27 | } 28 | } 29 | 30 | private func register(_ req: Request) throws -> EventLoopFuture { 31 | try RegisterRequest.validate(req) 32 | let registerRequest = try req.content.decode(RegisterRequest.self) 33 | guard registerRequest.password == registerRequest.confirmPassword else { 34 | throw AuthenticationError.passwordsDontMatch 35 | } 36 | 37 | return req.password 38 | .async 39 | .hash(registerRequest.password) 40 | .flatMapThrowing { try User(from: registerRequest, hash: $0) } 41 | .flatMap { user in 42 | req.users 43 | .create(user) 44 | .flatMapErrorThrowing { 45 | if let dbError = $0 as? DatabaseError, dbError.isConstraintFailure { 46 | throw AuthenticationError.emailAlreadyExists 47 | } 48 | throw $0 49 | } 50 | .flatMap { req.emailVerifier.verify(for: user) } 51 | } 52 | .transform(to: .created) 53 | } 54 | 55 | private func login(_ req: Request) throws -> EventLoopFuture { 56 | try LoginRequest.validate(req) 57 | let loginRequest = try req.content.decode(LoginRequest.self) 58 | 59 | return req.users 60 | .find(email: loginRequest.email) 61 | .unwrap(or: AuthenticationError.invalidEmailOrPassword) 62 | .guard({ $0.isEmailVerified }, else: AuthenticationError.emailIsNotVerified) 63 | .flatMap { user -> EventLoopFuture in 64 | return req.password 65 | .async 66 | .verify(loginRequest.password, created: user.passwordHash) 67 | .guard({ $0 == true }, else: AuthenticationError.invalidEmailOrPassword) 68 | .transform(to: user) 69 | } 70 | .flatMap { user -> EventLoopFuture in 71 | do { 72 | return try req.refreshTokens.delete(for: user.requireID()).transform(to: user) 73 | } catch { 74 | return req.eventLoop.makeFailedFuture(error) 75 | } 76 | } 77 | .flatMap { user in 78 | do { 79 | let token = req.random.generate(bits: 256) 80 | let refreshToken = try RefreshToken(token: SHA256.hash(token), userID: user.requireID()) 81 | 82 | return req.refreshTokens 83 | .create(refreshToken) 84 | .flatMapThrowing { 85 | try LoginResponse( 86 | user: UserDTO(from: user), 87 | accessToken: req.jwt.sign(Payload(with: user)), 88 | refreshToken: token 89 | ) 90 | } 91 | } catch { 92 | return req.eventLoop.makeFailedFuture(error) 93 | } 94 | } 95 | } 96 | 97 | private func refreshAccessToken(_ req: Request) throws -> EventLoopFuture { 98 | let accessTokenRequest = try req.content.decode(AccessTokenRequest.self) 99 | let hashedRefreshToken = SHA256.hash(accessTokenRequest.refreshToken) 100 | 101 | return req.refreshTokens 102 | .find(token: hashedRefreshToken) 103 | .unwrap(or: AuthenticationError.refreshTokenOrUserNotFound) 104 | .flatMap { req.refreshTokens.delete($0).transform(to: $0) } 105 | .guard({ $0.expiresAt > Date() }, else: AuthenticationError.refreshTokenHasExpired) 106 | .flatMap { req.users.find(id: $0.$user.id) } 107 | .unwrap(or: AuthenticationError.refreshTokenOrUserNotFound) 108 | .flatMap { user in 109 | do { 110 | let token = req.random.generate(bits: 256) 111 | let refreshToken = try RefreshToken(token: SHA256.hash(token), userID: user.requireID()) 112 | 113 | let payload = try Payload(with: user) 114 | let accessToken = try req.jwt.sign(payload) 115 | 116 | return req.refreshTokens 117 | .create(refreshToken) 118 | .transform(to: (token, accessToken)) 119 | } catch { 120 | return req.eventLoop.makeFailedFuture(error) 121 | } 122 | } 123 | .map { AccessTokenResponse(refreshToken: $0, accessToken: $1) } 124 | } 125 | 126 | private func getCurrentUser(_ req: Request) throws -> EventLoopFuture { 127 | let payload = try req.auth.require(Payload.self) 128 | 129 | return req.users 130 | .find(id: payload.userID) 131 | .unwrap(or: AuthenticationError.userNotFound) 132 | .map { UserDTO(from: $0) } 133 | } 134 | 135 | private func verifyEmail(_ req: Request) throws -> EventLoopFuture { 136 | let token = try req.query.get(String.self, at: "token") 137 | 138 | let hashedToken = SHA256.hash(token) 139 | 140 | return req.emailTokens 141 | .find(token: hashedToken) 142 | .unwrap(or: AuthenticationError.emailTokenNotFound) 143 | .flatMap { req.emailTokens.delete($0).transform(to: $0) } 144 | .guard({ $0.expiresAt > Date() }, 145 | else: AuthenticationError.emailTokenHasExpired) 146 | .flatMap { 147 | req.users.set(\.$isEmailVerified, to: true, for: $0.$user.id) 148 | } 149 | .transform(to: .ok) 150 | } 151 | 152 | private func resetPassword(_ req: Request) throws -> EventLoopFuture { 153 | let resetPasswordRequest = try req.content.decode(ResetPasswordRequest.self) 154 | 155 | return req.users 156 | .find(email: resetPasswordRequest.email) 157 | .flatMap { 158 | if let user = $0 { 159 | return req.passwordResetter 160 | .reset(for: user) 161 | .transform(to: .noContent) 162 | } else { 163 | return req.eventLoop.makeSucceededFuture(.noContent) 164 | } 165 | } 166 | } 167 | 168 | private func verifyResetPasswordToken(_ req: Request) throws -> EventLoopFuture { 169 | let token = try req.query.get(String.self, at: "token") 170 | 171 | let hashedToken = SHA256.hash(token) 172 | 173 | return req.passwordTokens 174 | .find(token: hashedToken) 175 | .unwrap(or: AuthenticationError.invalidPasswordToken) 176 | .flatMap { passwordToken in 177 | guard passwordToken.expiresAt > Date() else { 178 | return req.passwordTokens 179 | .delete(passwordToken) 180 | .transform(to: req.eventLoop 181 | .makeFailedFuture(AuthenticationError.passwordTokenHasExpired) 182 | ) 183 | } 184 | 185 | return req.eventLoop.makeSucceededFuture(.noContent) 186 | } 187 | } 188 | 189 | private func recoverAccount(_ req: Request) throws -> EventLoopFuture { 190 | try RecoverAccountRequest.validate(req) 191 | let content = try req.content.decode(RecoverAccountRequest.self) 192 | 193 | guard content.password == content.confirmPassword else { 194 | throw AuthenticationError.passwordsDontMatch 195 | } 196 | 197 | let hashedToken = SHA256.hash(content.token) 198 | 199 | return req.passwordTokens 200 | .find(token: hashedToken) 201 | .unwrap(or: AuthenticationError.invalidPasswordToken) 202 | .flatMap { passwordToken -> EventLoopFuture in 203 | guard passwordToken.expiresAt > Date() else { 204 | return req.passwordTokens 205 | .delete(passwordToken) 206 | .transform(to: req.eventLoop 207 | .makeFailedFuture(AuthenticationError.passwordTokenHasExpired) 208 | ) 209 | } 210 | 211 | return req.password 212 | .async 213 | .hash(content.password) 214 | .flatMap { digest in 215 | req.users.set(\.$passwordHash, to: digest, for: passwordToken.$user.id) 216 | } 217 | .flatMap { req.passwordTokens.delete(for: passwordToken.$user.id) } 218 | } 219 | .transform(to: .noContent) 220 | } 221 | 222 | private func sendEmailVerification(_ req: Request) throws -> EventLoopFuture { 223 | let content = try req.content.decode(SendEmailVerificationRequest.self) 224 | 225 | return req.users 226 | .find(email: content.email) 227 | .flatMap { 228 | guard let user = $0, !user.isEmailVerified else { 229 | return req.eventLoop.makeSucceededFuture(.noContent) 230 | } 231 | 232 | return req.emailVerifier 233 | .verify(for: user) 234 | .transform(to: .noContent) 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /Sources/App/Emails/Email.swift: -------------------------------------------------------------------------------- 1 | protocol Email: Codable { 2 | var templateName: String { get } 3 | var templateData: [String: String] { get } 4 | var subject: String { get } 5 | } 6 | 7 | struct AnyEmail: Email { 8 | var templateName: String 9 | var templateData: [String : String] 10 | var subject: String 11 | 12 | init(_ email: E) where E: Email { 13 | self.templateData = email.templateData 14 | self.templateName = email.templateName 15 | self.subject = email.subject 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/App/Emails/ResetPasswordEmail.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ResetPasswordEmail: Email { 4 | let templateName: String = "reset_password" 5 | var templateData: [String : String] { 6 | ["reset_url": resetURL] 7 | } 8 | var subject: String { 9 | "Reset your password" 10 | } 11 | 12 | let resetURL: String 13 | 14 | init(resetURL: String) { 15 | self.resetURL = resetURL 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /Sources/App/Emails/VerificationEmail.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct VerificationEmail: Email { 4 | let templateName: String = "email_verification" 5 | let verifyUrl: String 6 | 7 | var subject: String { 8 | "Please verify your email" 9 | } 10 | 11 | var templateData: [String : String] { 12 | ["verify_url": verifyUrl] 13 | } 14 | 15 | init(verifyUrl: String) { 16 | self.verifyUrl = verifyUrl 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Errors/AppError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol AppError: AbortError, DebuggableError {} 4 | -------------------------------------------------------------------------------- /Sources/App/Errors/AuthenticationError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | enum AuthenticationError: AppError { 4 | case passwordsDontMatch 5 | case emailAlreadyExists 6 | case invalidEmailOrPassword 7 | case refreshTokenOrUserNotFound 8 | case refreshTokenHasExpired 9 | case userNotFound 10 | case emailTokenHasExpired 11 | case emailTokenNotFound 12 | case emailIsNotVerified 13 | case invalidPasswordToken 14 | case passwordTokenHasExpired 15 | } 16 | 17 | extension AuthenticationError: AbortError { 18 | var status: HTTPResponseStatus { 19 | switch self { 20 | case .passwordsDontMatch: 21 | return .badRequest 22 | case .emailAlreadyExists: 23 | return .badRequest 24 | case .emailTokenHasExpired: 25 | return .badRequest 26 | case .invalidEmailOrPassword: 27 | return .unauthorized 28 | case .refreshTokenOrUserNotFound: 29 | return .notFound 30 | case .userNotFound: 31 | return .notFound 32 | case .emailTokenNotFound: 33 | return .notFound 34 | case .refreshTokenHasExpired: 35 | return .unauthorized 36 | case .emailIsNotVerified: 37 | return .unauthorized 38 | case .invalidPasswordToken: 39 | return .notFound 40 | case .passwordTokenHasExpired: 41 | return .unauthorized 42 | } 43 | } 44 | 45 | var reason: String { 46 | switch self { 47 | case .passwordsDontMatch: 48 | return "Passwords did not match" 49 | case .emailAlreadyExists: 50 | return "A user with that email already exists" 51 | case .invalidEmailOrPassword: 52 | return "Email or password was incorrect" 53 | case .refreshTokenOrUserNotFound: 54 | return "User or refresh token was not found" 55 | case .refreshTokenHasExpired: 56 | return "Refresh token has expired" 57 | case .userNotFound: 58 | return "User was not found" 59 | case .emailTokenNotFound: 60 | return "Email token not found" 61 | case .emailTokenHasExpired: 62 | return "Email token has expired" 63 | case .emailIsNotVerified: 64 | return "Email is not verified" 65 | case .invalidPasswordToken: 66 | return "Invalid reset password token" 67 | case .passwordTokenHasExpired: 68 | return "Reset password token has expired" 69 | } 70 | } 71 | 72 | var identifier: String { 73 | switch self { 74 | case .passwordsDontMatch: 75 | return "passwords_dont_match" 76 | case .emailAlreadyExists: 77 | return "email_already_exists" 78 | case .invalidEmailOrPassword: 79 | return "invalid_email_or_password" 80 | case .refreshTokenOrUserNotFound: 81 | return "refresh_token_or_user_not_found" 82 | case .refreshTokenHasExpired: 83 | return "refresh_token_has_expired" 84 | case .userNotFound: 85 | return "user_not_found" 86 | case .emailTokenNotFound: 87 | return "email_token_not_found" 88 | case .emailTokenHasExpired: 89 | return "email_token_has_expired" 90 | case .emailIsNotVerified: 91 | return "email_is_not_verified" 92 | case .invalidPasswordToken: 93 | return "invalid_password_token" 94 | case .passwordTokenHasExpired: 95 | return "password_token_has_expired" 96 | } 97 | } 98 | 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Data+Base64URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | public func base64URLEncodedString(options: Data.Base64EncodingOptions = []) -> String { 5 | return base64EncodedString(options: options).base64URLEscaped() 6 | } 7 | } 8 | 9 | extension String { 10 | public func base64URLEscaped() -> String { 11 | return replacingOccurrences(of: "+", with: "-") 12 | .replacingOccurrences(of: "/", with: "_") 13 | .replacingOccurrences(of: "=", with: "") 14 | } 15 | 16 | public func base64URLUnescaped() -> String { 17 | let replaced = replacingOccurrences(of: "-", with: "+") 18 | .replacingOccurrences(of: "_", with: "/") 19 | /// https://stackoverflow.com/questions/43499651/decode-base64url-to-base64-swift 20 | let padding = replaced.count % 4 21 | if padding > 0 { 22 | return replaced + String(repeating: "=", count: 4 - padding) 23 | } else { 24 | return replaced 25 | } 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Mailgun+Domains.swift: -------------------------------------------------------------------------------- 1 | import Mailgun 2 | 3 | extension MailgunDomain { 4 | static var sandbox: MailgunDomain { .init("sandbox24eb708b9cb044748fb5f75945feb815.mailgun.org", .us)} 5 | } 6 | -------------------------------------------------------------------------------- /Sources/App/Extensions/QueueContext+Services.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Queues 3 | import Mailgun 4 | 5 | extension QueueContext { 6 | var db: Database { 7 | application.databases 8 | .database(logger: self.logger, on: self.eventLoop)! 9 | } 10 | 11 | func mailgun() -> MailgunProvider { 12 | application.mailgun().delegating(to: self.eventLoop) 13 | } 14 | 15 | func mailgun(_ domain: MailgunDomain? = nil) -> MailgunProvider { 16 | application.mailgun(domain).delegating(to: self.eventLoop) 17 | } 18 | 19 | var appConfig: AppConfig { 20 | application.config 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Request+Services.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Request { 4 | // MARK: Repositories 5 | var users: UserRepository { application.repositories.users.for(self) } 6 | var refreshTokens: RefreshTokenRepository { application.repositories.refreshTokens.for(self) } 7 | var emailTokens: EmailTokenRepository { application.repositories.emailTokens.for(self) } 8 | var passwordTokens: PasswordTokenRepository { application.repositories.passwordTokens.for(self) } 9 | 10 | // var email: EmailVerifier { application.emailVerifiers.verifier.for(self) } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/App/Extensions/SHA256+Base64.swift: -------------------------------------------------------------------------------- 1 | import Crypto 2 | import Foundation 3 | 4 | extension SHA256Digest { 5 | var base64: String { 6 | Data(self).base64EncodedString() 7 | } 8 | 9 | var base64URLEncoded: String { 10 | Data(self).base64URLEncodedString() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/App/Extensions/SHA256+String.swift: -------------------------------------------------------------------------------- 1 | import Crypto 2 | import Foundation 3 | 4 | extension SHA256 { 5 | /// Returns hex-encoded string 6 | static func hash(_ string: String) -> String { 7 | SHA256.hash(data: string.data(using: .utf8)!) 8 | } 9 | 10 | /// Returns a hex encoded string 11 | static func hash(data: D) -> String where D : DataProtocol { 12 | SHA256.hash(data: data).hex 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Jobs/EmailJob.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Queues 3 | import Mailgun 4 | 5 | struct EmailPayload: Codable { 6 | let email: AnyEmail 7 | let recipient: String 8 | 9 | init(_ email: E, to recipient: String) { 10 | self.email = AnyEmail(email) 11 | self.recipient = recipient 12 | } 13 | } 14 | 15 | struct EmailJob: Job { 16 | typealias Payload = EmailPayload 17 | 18 | func dequeue(_ context: QueueContext, _ payload: EmailPayload) -> EventLoopFuture { 19 | let mailgunMessage = MailgunTemplateMessage( 20 | from: context.appConfig.noReplyEmail, 21 | to: payload.recipient, 22 | subject: payload.email.subject, 23 | template: payload.email.templateName, 24 | templateData: payload.email.templateData 25 | ) 26 | 27 | return context.mailgun().send(mailgunMessage).transform(to: ()) 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /Sources/App/Middleware/ErrorMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ErrorResponse: Codable { 4 | var error: Bool 5 | var reason: String 6 | var errorCode: String? 7 | } 8 | 9 | extension ErrorMiddleware { 10 | static func `custom`(environment: Environment) -> ErrorMiddleware { 11 | return .init { req, error in 12 | let status: HTTPResponseStatus 13 | let reason: String 14 | let headers: HTTPHeaders 15 | let errorCode: String? 16 | 17 | switch error { 18 | case let appError as AppError: 19 | reason = appError.reason 20 | status = appError.status 21 | headers = appError.headers 22 | errorCode = appError.identifier 23 | case let abort as AbortError: 24 | // this is an abort error, we should use its status, reason, and headers 25 | reason = abort.reason 26 | status = abort.status 27 | headers = abort.headers 28 | errorCode = nil 29 | case let error as LocalizedError where !environment.isRelease: 30 | // if not release mode, and error is debuggable, provide debug 31 | // info directly to the developer 32 | reason = error.localizedDescription 33 | status = .internalServerError 34 | headers = [:] 35 | errorCode = nil 36 | default: 37 | // not an abort error, and not debuggable or in dev mode 38 | // just deliver a generic 500 to avoid exposing any sensitive error info 39 | reason = "Something went wrong." 40 | status = .internalServerError 41 | headers = [:] 42 | errorCode = nil 43 | } 44 | 45 | // Report the error to logger. 46 | req.logger.report(error: error) 47 | 48 | // create a Response with appropriate status 49 | let response = Response(status: status, headers: headers) 50 | 51 | // attempt to serialize the error to json 52 | do { 53 | let errorResponse = ErrorResponse(error: true, reason: reason, errorCode: errorCode) 54 | response.body = try .init(data: JSONEncoder().encode(errorResponse)) 55 | response.headers.replaceOrAdd(name: .contentType, value: "application/json; charset=utf-8") 56 | } catch { 57 | response.body = .init(string: "Oops: \(error)") 58 | response.headers.replaceOrAdd(name: .contentType, value: "text/plain; charset=utf-8") 59 | } 60 | return response 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateEmailToken.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateEmailToken: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | return database.schema("user_email_tokens") 6 | .id() 7 | .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade)) 8 | .field("token", .string, .required) 9 | .field("expires_at", .datetime, .required) 10 | .unique(on: "user_id") 11 | .unique(on: "token") 12 | .create() 13 | } 14 | 15 | func revert(on database: Database) -> EventLoopFuture { 16 | return database.schema("user_email_tokens").delete() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreatePasswordToken.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreatePasswordToken: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | database.schema("user_password_tokens") 6 | .id() 7 | .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade)) 8 | .field("token", .string, .required) 9 | .field("expires_at", .datetime, .required) 10 | .create() 11 | } 12 | 13 | func revert(on database: Database) -> EventLoopFuture { 14 | database.schema("user_password_tokens").delete() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateRefreshToken.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateRefreshToken: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | return database.schema("user_refresh_tokens") 6 | .id() 7 | .field("token", .string) 8 | .field("user_id", .uuid, .references("users", "id", onDelete: .cascade)) 9 | .field("expires_at", .datetime) 10 | .field("issued_at", .datetime) 11 | .unique(on: "token") 12 | .unique(on: "user_id") 13 | .create() 14 | } 15 | 16 | func revert(on database: Database) -> EventLoopFuture { 17 | return database.schema("user_refresh_tokens").delete() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateUser.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateUser: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | return database.schema("users") 6 | .id() 7 | .field("full_name", .string, .required) 8 | .field("email", .string, .required) 9 | .field("password_hash", .string, .required) 10 | .field("is_admin", .bool, .required, .custom("DEFAULT FALSE")) 11 | .field("is_email_verified", .bool, .required, .custom("DEFAULT FALSE")) 12 | .unique(on: "email") 13 | .create() 14 | } 15 | 16 | func revert(on database: Database) -> EventLoopFuture { 17 | return database.schema("users").delete() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/App/Models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsodgaard/vapor-auth-template/fc3f4a7d8a6c3f74305c38718f0798728d46fda1/Sources/App/Models/.gitkeep -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/AccessToken/AccessTokenRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct AccessTokenRequest: Content { 4 | let refreshToken: String 5 | } 6 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/AccessToken/AccessTokenResponse.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct AccessTokenResponse: Content { 4 | let refreshToken: String 5 | let accessToken: String 6 | } 7 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/EmailVerification/SendEmailVerificationRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct SendEmailVerificationRequest: Content { 4 | let email: String 5 | } 6 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/Login/LoginRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct LoginRequest: Content { 4 | let email: String 5 | let password: String 6 | } 7 | 8 | extension LoginRequest: Validatable { 9 | static func validations(_ validations: inout Validations) { 10 | validations.add("email", as: String.self, is: .email) 11 | validations.add("password", as: String.self, is: !.empty) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/Login/LoginResponse.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct LoginResponse: Content { 4 | let user: UserDTO 5 | let accessToken: String 6 | let refreshToken: String 7 | } 8 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/Register/RegisterRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct RegisterRequest: Content { 4 | let fullName: String 5 | let email: String 6 | let password: String 7 | let confirmPassword: String 8 | } 9 | 10 | extension RegisterRequest: Validatable { 11 | static func validations(_ validations: inout Validations) { 12 | validations.add("fullName", as: String.self, is: .count(3...)) 13 | validations.add("email", as: String.self, is: .email) 14 | validations.add("password", as: String.self, is: .count(8...)) 15 | } 16 | } 17 | 18 | extension User { 19 | convenience init(from register: RegisterRequest, hash: String) throws { 20 | self.init(fullName: register.fullName, email: register.email, passwordHash: hash) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/ResetPassword/RecoverAccountRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct RecoverAccountRequest: Content { 4 | let password: String 5 | let confirmPassword: String 6 | let token: String 7 | } 8 | 9 | extension RecoverAccountRequest: Validatable { 10 | static func validations(_ validations: inout Validations) { 11 | validations.add("password", as: String.self, is: .count(8...)) 12 | validations.add("confirmPassword", as: String.self, is: !.empty) 13 | validations.add("token", as: String.self, is: !.empty) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Authentication/ResetPassword/ResetPasswordRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ResetPasswordRequest: Content { 4 | let email: String 5 | } 6 | 7 | extension ResetPasswordRequest: Validatable { 8 | static func validations(_ validations: inout Validations) { 9 | validations.add("email", as: String.self, is: .email) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/App/Models/DTO/Users/UserDTO.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct UserDTO: Content { 4 | let id: UUID? 5 | let fullName: String 6 | let email: String 7 | let isAdmin: Bool 8 | 9 | init(id: UUID? = nil, fullName: String, email: String, isAdmin: Bool) { 10 | self.id = id 11 | self.fullName = fullName 12 | self.email = email 13 | self.isAdmin = isAdmin 14 | } 15 | 16 | init(from user: User) { 17 | self.init(id: user.id, fullName: user.fullName, email: user.email, isAdmin: user.isAdmin) 18 | } 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /Sources/App/Models/Entities/EmailToken.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class EmailToken: Model { 5 | static let schema = "user_email_tokens" 6 | 7 | @ID(key: .id) 8 | var id: UUID? 9 | 10 | @Parent(key: "user_id") 11 | var user: User 12 | 13 | @Field(key: "token") 14 | var token: String 15 | 16 | @Field(key: "expires_at") 17 | var expiresAt: Date 18 | 19 | init() {} 20 | 21 | init( 22 | id: UUID? = nil, 23 | userID: UUID, 24 | token: String, 25 | expiresAt: Date = Date().addingTimeInterval(Constants.EMAIL_TOKEN_LIFETIME) 26 | ) { 27 | self.id = id 28 | self.$user.id = userID 29 | self.token = token 30 | self.expiresAt = expiresAt 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/App/Models/Entities/PasswordToken.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class PasswordToken: Model { 5 | static var schema: String = "user_password_tokens" 6 | 7 | @ID(key: .id) 8 | var id: UUID? 9 | 10 | @Parent(key: "user_id") 11 | var user: User 12 | 13 | @Field(key: "token") 14 | var token: String 15 | 16 | @Field(key: "expires_at") 17 | var expiresAt: Date 18 | 19 | init() {} 20 | 21 | init(id: UUID? = nil, userID: UUID, token: String, expiresAt: Date = Date().addingTimeInterval(Constants.RESET_PASSWORD_TOKEN_LIFETIME)) { 22 | self.id = id 23 | self.$user.id = userID 24 | self.token = token 25 | self.expiresAt = expiresAt 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Sources/App/Models/Entities/RefreshToken.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class RefreshToken: Model { 5 | static let schema = "user_refresh_tokens" 6 | 7 | @ID(key: .id) 8 | var id: UUID? 9 | 10 | @Field(key: "token") 11 | var token: String 12 | 13 | @Parent(key: "user_id") 14 | var user: User 15 | 16 | @Field(key: "expires_at") 17 | var expiresAt: Date 18 | 19 | @Field(key: "issued_at") 20 | var issuedAt: Date 21 | 22 | init() {} 23 | 24 | init(id: UUID? = nil, token: String, userID: UUID, expiresAt: Date = Date().addingTimeInterval(Constants.REFRESH_TOKEN_LIFETIME), issuedAt: Date = Date()) { 25 | self.id = id 26 | self.token = token 27 | self.$user.id = userID 28 | self.expiresAt = expiresAt 29 | self.issuedAt = issuedAt 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Models/Entities/User.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class User: Model, Authenticatable { 5 | static let schema = "users" 6 | 7 | @ID(key: .id) 8 | var id: UUID? 9 | 10 | @Field(key: "full_name") 11 | var fullName: String 12 | 13 | @Field(key: "email") 14 | var email: String 15 | 16 | @Field(key: "password_hash") 17 | var passwordHash: String 18 | 19 | @Field(key: "is_admin") 20 | var isAdmin: Bool 21 | 22 | @Field(key: "is_email_verified") 23 | var isEmailVerified: Bool 24 | 25 | init() {} 26 | 27 | init( 28 | id: UUID? = nil, 29 | fullName: String, 30 | email: String, 31 | passwordHash: String, 32 | isAdmin: Bool = false, 33 | isEmailVerified: Bool = false 34 | ) { 35 | self.id = id 36 | self.fullName = fullName 37 | self.email = email 38 | self.passwordHash = passwordHash 39 | self.isAdmin = isAdmin 40 | self.isEmailVerified = isEmailVerified 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/App/Models/JWT/Payload.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import JWT 3 | 4 | struct Payload: JWTPayload, Authenticatable { 5 | // User-releated stuff 6 | var userID: UUID 7 | var fullName: String 8 | var email: String 9 | var isAdmin: Bool 10 | 11 | // JWT stuff 12 | var exp: ExpirationClaim 13 | 14 | func verify(using signer: JWTSigner) throws { 15 | try self.exp.verifyNotExpired() 16 | } 17 | 18 | init(with user: User) throws { 19 | self.userID = try user.requireID() 20 | self.fullName = user.fullName 21 | self.email = user.email 22 | self.isAdmin = user.isAdmin 23 | self.exp = ExpirationClaim(value: Date().addingTimeInterval(Constants.ACCESS_TOKEN_LIFETIME)) 24 | } 25 | } 26 | 27 | extension User { 28 | convenience init(from payload: Payload) { 29 | self.init(id: payload.userID, fullName: payload.fullName, email: payload.email, passwordHash: "", isAdmin: payload.isAdmin) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Repositories/EmailTokenRepository.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | protocol EmailTokenRepository: Repository { 5 | func find(token: String) -> EventLoopFuture 6 | func create(_ emailToken: EmailToken) -> EventLoopFuture 7 | func delete(_ emailToken: EmailToken) -> EventLoopFuture 8 | func find(userID: UUID) -> EventLoopFuture 9 | } 10 | 11 | struct DatabaseEmailTokenRepository: EmailTokenRepository, DatabaseRepository { 12 | let database: Database 13 | 14 | func find(token: String) -> EventLoopFuture { 15 | return EmailToken.query(on: database) 16 | .filter(\.$token == token) 17 | .first() 18 | } 19 | 20 | func create(_ emailToken: EmailToken) -> EventLoopFuture { 21 | return emailToken.create(on: database) 22 | } 23 | 24 | func delete(_ emailToken: EmailToken) -> EventLoopFuture { 25 | return emailToken.delete(on: database) 26 | } 27 | 28 | func find(userID: UUID) -> EventLoopFuture { 29 | EmailToken.query(on: database) 30 | .filter(\.$user.$id == userID) 31 | .first() 32 | } 33 | } 34 | 35 | extension Application.Repositories { 36 | var emailTokens: EmailTokenRepository { 37 | guard let factory = storage.makeEmailTokenRepository else { 38 | fatalError("EmailToken repository not configured, use: app.repositories.use") 39 | } 40 | return factory(app) 41 | } 42 | 43 | func use(_ make: @escaping (Application) -> (EmailTokenRepository)) { 44 | storage.makeEmailTokenRepository = make 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/App/Repositories/PasswordTokenRepository.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | protocol PasswordTokenRepository: Repository { 5 | func find(userID: UUID) -> EventLoopFuture 6 | func find(token: String) -> EventLoopFuture 7 | func count() -> EventLoopFuture 8 | func create(_ passwordToken: PasswordToken) -> EventLoopFuture 9 | func delete(_ passwordToken: PasswordToken) -> EventLoopFuture 10 | func delete(for userID: UUID) -> EventLoopFuture 11 | } 12 | 13 | struct DatabasePasswordTokenRepository: PasswordTokenRepository, DatabaseRepository { 14 | var database: Database 15 | 16 | func find(userID: UUID) -> EventLoopFuture { 17 | PasswordToken.query(on: database) 18 | .filter(\.$user.$id == userID) 19 | .first() 20 | } 21 | 22 | func find(token: String) -> EventLoopFuture { 23 | PasswordToken.query(on: database) 24 | .filter(\.$token == token) 25 | .first() 26 | } 27 | 28 | func count() -> EventLoopFuture { 29 | PasswordToken.query(on: database).count() 30 | } 31 | 32 | func create(_ passwordToken: PasswordToken) -> EventLoopFuture { 33 | passwordToken.create(on: database) 34 | } 35 | 36 | func delete(_ passwordToken: PasswordToken) -> EventLoopFuture { 37 | passwordToken.delete(on: database) 38 | } 39 | 40 | func delete(for userID: UUID) -> EventLoopFuture { 41 | PasswordToken.query(on: database) 42 | .filter(\.$user.$id == userID) 43 | .delete() 44 | } 45 | } 46 | 47 | extension Application.Repositories { 48 | var passwordTokens: PasswordTokenRepository { 49 | guard let factory = storage.makePasswordTokenRepository else { 50 | fatalError("PasswordToken repository not configured, use: app.repositories.use") 51 | } 52 | return factory(app) 53 | } 54 | 55 | func use(_ make: @escaping (Application) -> (PasswordTokenRepository)) { 56 | storage.makePasswordTokenRepository = make 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/App/Repositories/RefreshTokenRepository.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | protocol RefreshTokenRepository: Repository { 5 | func create(_ token: RefreshToken) -> EventLoopFuture 6 | func find(id: UUID?) -> EventLoopFuture 7 | func find(token: String) -> EventLoopFuture 8 | func delete(_ token: RefreshToken) -> EventLoopFuture 9 | func count() -> EventLoopFuture 10 | func delete(for userID: UUID) -> EventLoopFuture 11 | } 12 | 13 | struct DatabaseRefreshTokenRepository: RefreshTokenRepository, DatabaseRepository { 14 | let database: Database 15 | 16 | func create(_ token: RefreshToken) -> EventLoopFuture { 17 | return token.create(on: database) 18 | } 19 | 20 | func find(id: UUID?) -> EventLoopFuture { 21 | return RefreshToken.find(id, on: database) 22 | } 23 | 24 | func find(token: String) -> EventLoopFuture { 25 | return RefreshToken.query(on: database) 26 | .filter(\.$token == token) 27 | .first() 28 | } 29 | 30 | func delete(_ token: RefreshToken) -> EventLoopFuture { 31 | token.delete(on: database) 32 | } 33 | 34 | func count() -> EventLoopFuture { 35 | return RefreshToken.query(on: database) 36 | .count() 37 | } 38 | 39 | func delete(for userID: UUID) -> EventLoopFuture { 40 | RefreshToken.query(on: database) 41 | .filter(\.$user.$id == userID) 42 | .delete() 43 | } 44 | } 45 | 46 | extension Application.Repositories { 47 | var refreshTokens: RefreshTokenRepository { 48 | guard let factory = storage.makeRefreshTokenRepository else { 49 | fatalError("RefreshToken repository not configured, use: app.repositories.use") 50 | } 51 | return factory(app) 52 | } 53 | 54 | func use(_ make: @escaping (Application) -> (RefreshTokenRepository)) { 55 | storage.makeRefreshTokenRepository = make 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/App/Repositories/UserRepository.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | protocol UserRepository: Repository { 5 | func create(_ user: User) -> EventLoopFuture 6 | func delete(id: UUID) -> EventLoopFuture 7 | func all() -> EventLoopFuture<[User]> 8 | func find(id: UUID?) -> EventLoopFuture 9 | func find(email: String) -> EventLoopFuture 10 | func set(_ field: KeyPath, to value: Field.Value, for userID: UUID) -> EventLoopFuture where Field: QueryableProperty, Field.Model == User 11 | func count() -> EventLoopFuture 12 | } 13 | 14 | struct DatabaseUserRepository: UserRepository, DatabaseRepository { 15 | let database: Database 16 | 17 | func create(_ user: User) -> EventLoopFuture { 18 | return user.create(on: database) 19 | } 20 | 21 | func delete(id: UUID) -> EventLoopFuture { 22 | return User.query(on: database) 23 | .filter(\.$id == id) 24 | .delete() 25 | } 26 | 27 | func all() -> EventLoopFuture<[User]> { 28 | return User.query(on: database).all() 29 | } 30 | 31 | func find(id: UUID?) -> EventLoopFuture { 32 | return User.find(id, on: database) 33 | } 34 | 35 | func find(email: String) -> EventLoopFuture { 36 | return User.query(on: database) 37 | .filter(\.$email == email) 38 | .first() 39 | } 40 | 41 | func set(_ field: KeyPath, to value: Field.Value, for userID: UUID) -> EventLoopFuture 42 | where Field: QueryableProperty, Field.Model == User 43 | { 44 | return User.query(on: database) 45 | .filter(\.$id == userID) 46 | .set(field, to: value) 47 | .update() 48 | } 49 | 50 | func count() -> EventLoopFuture { 51 | return User.query(on: database).count() 52 | } 53 | } 54 | 55 | extension Application.Repositories { 56 | var users: UserRepository { 57 | guard let storage = storage.makeUserRepository else { 58 | fatalError("UserRepository not configured, use: app.userRepository.use()") 59 | } 60 | 61 | return storage(app) 62 | } 63 | 64 | func use(_ make: @escaping (Application) -> (UserRepository)) { 65 | storage.makeUserRepository = make 66 | } 67 | } 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /Sources/App/Services/EmailVerifier.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Queues 3 | 4 | struct EmailVerifier { 5 | let emailTokenRepository: EmailTokenRepository 6 | let config: AppConfig 7 | let queue: Queue 8 | let eventLoop: EventLoop 9 | let generator: RandomGenerator 10 | 11 | func verify(for user: User) -> EventLoopFuture { 12 | do { 13 | let token = generator.generate(bits: 256) 14 | let emailToken = try EmailToken(userID: user.requireID(), token: SHA256.hash(token)) 15 | let verifyUrl = url(token: token) 16 | return emailTokenRepository.create(emailToken).flatMap { 17 | self.queue.dispatch(EmailJob.self, .init(VerificationEmail(verifyUrl: verifyUrl), to: user.email)) 18 | } 19 | } catch { 20 | return eventLoop.makeFailedFuture(error) 21 | } 22 | } 23 | 24 | private func url(token: String) -> String { 25 | #"\#(config.apiURL)/auth/email-verification?token=\#(token)"# 26 | } 27 | } 28 | 29 | extension Application { 30 | var emailVerifier: EmailVerifier { 31 | .init(emailTokenRepository: self.repositories.emailTokens, config: self.config, queue: self.queues.queue, eventLoop: eventLoopGroup.next(), generator: self.random) 32 | } 33 | } 34 | 35 | extension Request { 36 | var emailVerifier: EmailVerifier { 37 | .init(emailTokenRepository: self.emailTokens, config: application.config, queue: self.queue, eventLoop: eventLoop, generator: self.application.random) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /Sources/App/Services/PasswordResetter.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Queues 3 | 4 | struct PasswordResetter { 5 | let queue: Queue 6 | let repository: PasswordTokenRepository 7 | let eventLoop: EventLoop 8 | let config: AppConfig 9 | let generator: RandomGenerator 10 | 11 | /// Sends a email to the user with a reset-password URL 12 | func reset(for user: User) -> EventLoopFuture { 13 | do { 14 | let token = generator.generate(bits: 256) 15 | let resetPasswordToken = try PasswordToken(userID: user.requireID(), token: SHA256.hash(token)) 16 | let url = resetURL(for: token) 17 | let email = ResetPasswordEmail(resetURL: url) 18 | return repository.create(resetPasswordToken).flatMap { 19 | self.queue.dispatch(EmailJob.self, .init(email, to: user.email)) 20 | } 21 | } catch { 22 | return eventLoop.makeFailedFuture(error) 23 | } 24 | } 25 | 26 | private func resetURL(for token: String) -> String { 27 | "\(config.frontendURL)/auth/reset-password?token=\(token)" 28 | } 29 | } 30 | 31 | extension Request { 32 | var passwordResetter: PasswordResetter { 33 | .init(queue: self.queue, repository: self.passwordTokens, eventLoop: self.eventLoop, config: self.application.config, generator: self.application.random) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/App/Services/RandomGenerator/Application+RandomGenerator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Application { 4 | public var random: AppRandomGenerator { 5 | .init(app: self) 6 | } 7 | 8 | public struct AppRandomGenerator: RandomGenerator { 9 | let app: Application 10 | 11 | var generator: RandomGenerator { 12 | guard let makeGenerator = app.randomGenerators.storage.makeGenerator else { 13 | fatalError("randomGenerators not configured, please use: app.randomGenerators.use") 14 | } 15 | 16 | return makeGenerator(app) 17 | } 18 | 19 | public func generate(bits: Int) -> String { 20 | generator.generate(bits: bits) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Services/RandomGenerator/Application+RandomGenerators.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Crypto 3 | 4 | public protocol RandomGenerator { 5 | func generate(bits: Int) -> String 6 | } 7 | 8 | extension Application { 9 | public struct RandomGenerators { 10 | public struct Provider { 11 | let run: ((Application) -> Void) 12 | } 13 | 14 | public let app: Application 15 | 16 | 17 | public func use(_ provider: Provider) { 18 | provider.run(app) 19 | } 20 | 21 | public func use(_ makeGenerator: @escaping ((Application) -> RandomGenerator)) { 22 | storage.makeGenerator = makeGenerator 23 | } 24 | 25 | final class Storage { 26 | var makeGenerator: ((Application) -> RandomGenerator)? 27 | init() {} 28 | } 29 | 30 | private struct Key: StorageKey { 31 | typealias Value = Storage 32 | } 33 | 34 | var storage: Storage { 35 | if let existing = self.app.storage[Key.self] { 36 | return existing 37 | } else { 38 | let new = Storage() 39 | self.app.storage[Key.self] = new 40 | return new 41 | } 42 | } 43 | } 44 | 45 | public var randomGenerators: RandomGenerators { 46 | .init(app: self) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/App/Services/RandomGenerator/RealRandomGenerator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Application.RandomGenerators.Provider { 4 | static var random: Self { 5 | .init { 6 | $0.randomGenerators.use { _ in RealRandomGenerator() } 7 | } 8 | } 9 | } 10 | 11 | struct RealRandomGenerator: RandomGenerator { 12 | func generate(bits: Int) -> String { 13 | [UInt8].random(count: bits / 8).hex 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Services/RandomGenerator/Request+RandomGenerator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Request { 4 | var random: RandomGenerator { 5 | self.application.random 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/App/Services/Repositories.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | protocol Repository: RequestService {} 5 | 6 | protocol DatabaseRepository: Repository { 7 | var database: Database { get } 8 | init(database: Database) 9 | } 10 | 11 | extension DatabaseRepository { 12 | func `for`(_ req: Request) -> Self { 13 | return Self.init(database: req.db) 14 | } 15 | } 16 | 17 | extension Application { 18 | struct Repositories { 19 | struct Provider { 20 | static var database: Self { 21 | .init { 22 | $0.repositories.use { DatabaseUserRepository(database: $0.db) } 23 | $0.repositories.use { DatabaseEmailTokenRepository(database: $0.db) } 24 | $0.repositories.use { DatabaseRefreshTokenRepository(database: $0.db) } 25 | $0.repositories.use { DatabasePasswordTokenRepository(database: $0.db) } 26 | } 27 | } 28 | 29 | let run: (Application) -> () 30 | } 31 | 32 | final class Storage { 33 | var makeUserRepository: ((Application) -> UserRepository)? 34 | var makeEmailTokenRepository: ((Application) -> EmailTokenRepository)? 35 | var makeRefreshTokenRepository: ((Application) -> RefreshTokenRepository)? 36 | var makePasswordTokenRepository: ((Application) -> PasswordTokenRepository)? 37 | init() { } 38 | } 39 | 40 | struct Key: StorageKey { 41 | typealias Value = Storage 42 | } 43 | 44 | let app: Application 45 | 46 | func use(_ provider: Provider) { 47 | provider.run(app) 48 | } 49 | 50 | var storage: Storage { 51 | if app.storage[Key.self] == nil { 52 | app.storage[Key.self] = .init() 53 | } 54 | 55 | return app.storage[Key.self]! 56 | } 57 | } 58 | 59 | var repositories: Repositories { 60 | .init(app: self) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/App/Services/RequestService.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol RequestService { 4 | func `for`(_ req: Request) -> Self 5 | } 6 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Vapor 4 | import JWT 5 | import Mailgun 6 | import QueuesRedisDriver 7 | 8 | public func configure(_ app: Application) throws { 9 | // MARK: JWT 10 | if app.environment != .testing { 11 | let jwksFilePath = app.directory.workingDirectory + (Environment.get("JWKS_KEYPAIR_FILE") ?? "keypair.jwks") 12 | guard 13 | let jwks = FileManager.default.contents(atPath: jwksFilePath), 14 | let jwksString = String(data: jwks, encoding: .utf8) 15 | else { 16 | fatalError("Failed to load JWKS Keypair file at: \(jwksFilePath)") 17 | } 18 | try app.jwt.signers.use(jwksJSON: jwksString) 19 | } 20 | 21 | // MARK: Database 22 | // Configure PostgreSQL database 23 | app.databases.use( 24 | .postgres( 25 | hostname: Environment.get("POSTGRES_HOSTNAME") ?? "localhost", 26 | username: Environment.get("POSTGRES_USERNAME") ?? "vapor", 27 | password: Environment.get("POSTGRES_PASSWORD") ?? "password", 28 | database: Environment.get("POSTGRES_DATABASE") ?? "vapor" 29 | ), as: .psql) 30 | 31 | // MARK: Middleware 32 | app.middleware = .init() 33 | app.middleware.use(ErrorMiddleware.custom(environment: app.environment)) 34 | 35 | // MARK: Model Middleware 36 | 37 | // MARK: Mailgun 38 | app.mailgun.configuration = .environment 39 | app.mailgun.defaultDomain = .sandbox 40 | 41 | // MARK: App Config 42 | app.config = .environment 43 | 44 | try routes(app) 45 | try migrations(app) 46 | try queues(app) 47 | try services(app) 48 | 49 | 50 | if app.environment == .development { 51 | try app.autoMigrate().wait() 52 | try app.queues.startInProcessJobs() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/migrations.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | func migrations(_ app: Application) throws { 4 | // Initial Migrations 5 | app.migrations.add(CreateUser()) 6 | app.migrations.add(CreateRefreshToken()) 7 | app.migrations.add(CreateEmailToken()) 8 | app.migrations.add(CreatePasswordToken()) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/App/queues.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Queues 3 | 4 | func queues(_ app: Application) throws { 5 | // MARK: Queues Configuration 6 | if app.environment != .testing { 7 | try app.queues.use( 8 | .redis(url: 9 | Environment.get("REDIS_URL") ?? "redis://127.0.0.1:6379" 10 | ) 11 | ) 12 | } 13 | 14 | // MARK: Jobs 15 | app.queues.add(EmailJob()) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | func routes(_ app: Application) throws { 5 | app.group("api") { api in 6 | // Authentication 7 | try! api.register(collection: AuthenticationController()) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/App/services.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | func services(_ app: Application) throws { 4 | app.randomGenerators.use(.random) 5 | app.repositories.use(.database) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | 4 | var env = try Environment.detect() 5 | try LoggingSystem.bootstrap(from: &env) 6 | let app = Application(env) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | try app.run() 10 | -------------------------------------------------------------------------------- /Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsodgaard/vapor-auth-template/fc3f4a7d8a6c3f74305c38718f0798728d46fda1/Tests/.gitkeep -------------------------------------------------------------------------------- /Tests/AppTests/AuthenticationTests/AuthenticationTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | import Crypto 5 | 6 | final class AuthenticationTests: XCTestCase { 7 | var app: Application! 8 | var testWorld: TestWorld! 9 | 10 | override func setUpWithError() throws { 11 | app = Application(.testing) 12 | try configure(app) 13 | self.testWorld = try TestWorld(app: app) 14 | } 15 | 16 | override func tearDown() { 17 | app.shutdown() 18 | } 19 | 20 | func testGettingCurrentUser() throws { 21 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123", isAdmin: true) 22 | try app.repositories.users.create(user).wait() 23 | 24 | try app.test(.GET, "api/auth/me", user: user, afterResponse: { res in 25 | XCTAssertEqual(res.status, .ok) 26 | XCTAssertContent(UserDTO.self, res) { userContent in 27 | XCTAssertEqual(userContent.email, "test@test.com") 28 | XCTAssertEqual(userContent.fullName, "Test User") 29 | XCTAssertEqual(userContent.isAdmin, true) 30 | XCTAssertEqual(userContent.id, user.id) 31 | } 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/AppTests/AuthenticationTests/EmailVerificationTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import App 3 | import Fluent 4 | import XCTVapor 5 | import Crypto 6 | 7 | final class EmailVerificationTests: XCTestCase { 8 | var app: Application! 9 | var testWorld: TestWorld! 10 | let verifyURL = "api/auth/email-verification" 11 | 12 | override func setUpWithError() throws { 13 | app = Application(.testing) 14 | try configure(app) 15 | self.testWorld = try TestWorld(app: app) 16 | } 17 | 18 | override func tearDown() { 19 | app.shutdown() 20 | } 21 | 22 | func testVerifyingEmailHappyPath() throws { 23 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 24 | try app.repositories.users.create(user).wait() 25 | let expectedHash = SHA256.hash("token123") 26 | 27 | let emailToken = EmailToken(userID: try user.requireID(), token: expectedHash) 28 | try app.repositories.emailTokens.create(emailToken).wait() 29 | 30 | try app.test(.GET, verifyURL, beforeRequest: { req in 31 | try req.query.encode(["token": "token123"]) 32 | }, afterResponse: { res in 33 | XCTAssertEqual(res.status, .ok) 34 | let user = try XCTUnwrap(app.repositories.users.find(id: user.id!).wait()) 35 | XCTAssertEqual(user.isEmailVerified, true) 36 | let token = try app.repositories.emailTokens.find(userID: user.requireID()).wait() 37 | XCTAssertNil(token) 38 | }) 39 | } 40 | 41 | func testVerifyingEmailWithInvalidTokenFails() throws { 42 | try app.test(.GET, verifyURL, beforeRequest: { req in 43 | try req.query.encode(["token": "blabla"]) 44 | }, afterResponse: { res in 45 | XCTAssertResponseError(res, AuthenticationError.emailTokenNotFound) 46 | }) 47 | } 48 | 49 | func testVerifyingEmailWithExpiredTokenFails() throws { 50 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 51 | try app.repositories.users.create(user).wait() 52 | let expectedHash = SHA256.hash("token123") 53 | let emailToken = EmailToken(userID: try user.requireID(), token: expectedHash, expiresAt: Date().addingTimeInterval(-Constants.EMAIL_TOKEN_LIFETIME - 1) ) 54 | try app.repositories.emailTokens.create(emailToken).wait() 55 | 56 | try app.test(.GET, verifyURL, beforeRequest: { req in 57 | try req.query.encode(["token": "token123"]) 58 | }, afterResponse: { res in 59 | XCTAssertResponseError(res, AuthenticationError.emailTokenHasExpired) 60 | }) 61 | } 62 | 63 | func testResendEmailVerification() throws { 64 | app.randomGenerators.use(.rigged(value: "emailtoken")) 65 | 66 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 67 | try app.repositories.users.create(user).wait() 68 | 69 | let content = SendEmailVerificationRequest(email: "test@test.com") 70 | 71 | try app.test(.POST, "api/auth/email-verification", content: content, afterResponse: { res in 72 | XCTAssertEqual(res.status, .noContent) 73 | let emailToken = try app.repositories.emailTokens.find(token: SHA256.hash("emailtoken")).wait() 74 | XCTAssertNotNil(emailToken) 75 | 76 | let job = try XCTUnwrap(app.queues.test.first(EmailJob.self)) 77 | XCTAssertEqual(job.recipient, "test@test.com") 78 | XCTAssertEqual(job.email.templateName, "email_verification") 79 | XCTAssertEqual(job.email.templateData["verify_url"], "http://api.local/auth/email-verification?token=emailtoken") 80 | }) 81 | } 82 | } 83 | 84 | 85 | -------------------------------------------------------------------------------- /Tests/AppTests/AuthenticationTests/LoginTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | import Crypto 5 | 6 | final class LoginTests: XCTestCase { 7 | var app: Application! 8 | var testWorld: TestWorld! 9 | let loginPath = "api/auth/login" 10 | 11 | override func setUpWithError() throws { 12 | app = Application(.testing) 13 | try configure(app) 14 | testWorld = try TestWorld(app: app) 15 | } 16 | 17 | override func tearDown() { 18 | app.shutdown() 19 | } 20 | 21 | func testLoginHappyPath() throws { 22 | app.passwords.use(.plaintext) 23 | 24 | let user = try User(fullName: "Test User", email: "test@test.com", passwordHash: app.password.hash("password"), isEmailVerified: true) 25 | try app.repositories.users.create(user).wait() 26 | let loginRequest = LoginRequest(email: "test@test.com", password: "password") 27 | 28 | try app.test(.POST, loginPath, beforeRequest: { req in 29 | try req.content.encode(loginRequest) 30 | }, afterResponse: { res in 31 | XCTAssertEqual(res.status, .ok) 32 | XCTAssertContent(LoginResponse.self, res) { login in 33 | XCTAssertEqual(login.user.email, "test@test.com") 34 | XCTAssertEqual(login.user.fullName, "Test User") 35 | XCTAssert(!login.refreshToken.isEmpty) 36 | XCTAssert(!login.accessToken.isEmpty) 37 | } 38 | }) 39 | } 40 | 41 | func testLoginWithNonExistingUserFails() throws { 42 | let loginRequest = LoginRequest(email: "none@login.com", password: "123") 43 | 44 | try app.test(.POST, loginPath, beforeRequest: { req in 45 | try req.content.encode(loginRequest) 46 | }, afterResponse: { res in 47 | XCTAssertResponseError(res, AuthenticationError.invalidEmailOrPassword) 48 | }) 49 | } 50 | 51 | func testLoginWithIncorrectPasswordFails() throws { 52 | app.passwords.use(.plaintext) 53 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "password", isEmailVerified: true) 54 | try app.repositories.users.create(user).wait() 55 | 56 | let loginRequest = LoginRequest(email: "test@test.com", password: "wrongpassword") 57 | 58 | try app.test(.POST, loginPath, beforeRequest: { req in 59 | try req.content.encode(loginRequest) 60 | }, afterResponse: { res in 61 | XCTAssertResponseError(res, AuthenticationError.invalidEmailOrPassword) 62 | }) 63 | } 64 | 65 | func testLoginRequiresEmailVerification() throws { 66 | app.passwords.use(.plaintext) 67 | 68 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "password", isEmailVerified: false) 69 | try app.repositories.users.create(user).wait() 70 | 71 | let loginRequest = LoginRequest(email: "test@test.com", password: "password") 72 | 73 | try app.test(.POST, loginPath, beforeRequest: { req in 74 | try req.content.encode(loginRequest) 75 | }, afterResponse: { res in 76 | XCTAssertResponseError(res, AuthenticationError.emailIsNotVerified) 77 | }) 78 | } 79 | 80 | func testLoginDeletesOldRefreshTokens() throws { 81 | app.passwords.use(.plaintext) 82 | 83 | let user = try User(fullName: "Test User", email: "test@test.com", passwordHash: app.password.hash("password"), isEmailVerified: true) 84 | try app.repositories.users.create(user).wait() 85 | let loginRequest = LoginRequest(email: "test@test.com", password: "password") 86 | let token = app.random.generate(bits: 256) 87 | let refreshToken = try RefreshToken(token: SHA256.hash(token), userID: user.requireID()) 88 | try app.repositories.refreshTokens.create(refreshToken).wait() 89 | 90 | try app.test(.POST, loginPath, beforeRequest: { req in 91 | try req.content.encode(loginRequest) 92 | }, afterResponse: { res in 93 | XCTAssertEqual(res.status, .ok) 94 | let tokenCount = try app.repositories.refreshTokens.count().wait() 95 | XCTAssertEqual(tokenCount, 1) 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/AppTests/AuthenticationTests/RegisterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | import Crypto 5 | 6 | final class RegisterTests: XCTestCase { 7 | var app: Application! 8 | var testWorld: TestWorld! 9 | let registerPath = "api/auth/register" 10 | 11 | override func setUpWithError() throws { 12 | app = Application(.testing) 13 | try configure(app) 14 | self.testWorld = try TestWorld(app: app) 15 | } 16 | 17 | override func tearDown() { 18 | app.shutdown() 19 | } 20 | 21 | func testRegisterHappyPath() throws { 22 | app.randomGenerators.use(.rigged(value: "token")) 23 | 24 | let data = RegisterRequest(fullName: "Test User", email: "test@test.com", password: "password123", confirmPassword: "password123") 25 | 26 | try app.test(.POST, registerPath, beforeRequest: { req in 27 | try req.content.encode(data) 28 | }, afterResponse: { res in 29 | XCTAssertEqual(res.status, .created) 30 | 31 | let user = try XCTUnwrap(app.repositories.users.find(email: "test@test.com").wait()) 32 | XCTAssertEqual(user.isAdmin, false) 33 | XCTAssertEqual(user.fullName, "Test User") 34 | XCTAssertEqual(user.email, "test@test.com") 35 | XCTAssertEqual(user.isEmailVerified, false) 36 | XCTAssertTrue(try BCryptDigest().verify("password123", created: user.passwordHash)) 37 | 38 | let emailToken = try app.repositories.emailTokens.find(token: SHA256.hash("token")).wait() 39 | XCTAssertEqual(emailToken?.$user.id, user.id) 40 | XCTAssertNotNil(emailToken) 41 | 42 | let job = try XCTUnwrap(app.queues.test.first(EmailJob.self)) 43 | XCTAssertEqual(job.recipient, "test@test.com") 44 | XCTAssertEqual(job.email.templateName, "email_verification") 45 | XCTAssertEqual(job.email.templateData["verify_url"], "http://api.local/auth/email-verification?token=token") 46 | }) 47 | } 48 | 49 | func testRegisterFailsWithNonMatchingPasswords() throws { 50 | let data = RegisterRequest(fullName: "Test User", email: "test@test.com", password: "12345678", confirmPassword: "124") 51 | 52 | try app.test(.POST, registerPath, beforeRequest: { request in 53 | try request.content.encode(data) 54 | }, afterResponse: { res in 55 | XCTAssertResponseError(res, AuthenticationError.passwordsDontMatch) 56 | XCTAssertEqual(try app.repositories.users.count().wait(), 0) 57 | }) 58 | } 59 | 60 | func testRegisterFailsWithExistingEmail() throws { 61 | try app.autoMigrate().wait() 62 | defer { try! app.autoRevert().wait() } 63 | 64 | app.repositories.use(.database) 65 | 66 | let user = User(fullName: "Test user 1", email: "test@test.com", passwordHash: "123") 67 | try user.create(on: app.db).wait() 68 | 69 | let registerRequest = RegisterRequest(fullName: "Test user 2", email: "test@test.com", password: "password123", confirmPassword: "password123") 70 | try app.test(.POST, registerPath, beforeRequest: { req in 71 | try req.content.encode(registerRequest) 72 | }, afterResponse: { res in 73 | XCTAssertResponseError(res, AuthenticationError.emailAlreadyExists) 74 | let users = try User.query(on: app.db).all().wait() 75 | XCTAssertEqual(users.count, 1) 76 | }) 77 | } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /Tests/AppTests/AuthenticationTests/ResetPasswordTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | import Crypto 5 | 6 | final class ResetPasswordTests: XCTestCase { 7 | var app: Application! 8 | var testWorld: TestWorld! 9 | 10 | override func setUpWithError() throws { 11 | app = Application(.testing) 12 | try configure(app) 13 | self.testWorld = try TestWorld(app: app) 14 | } 15 | 16 | override func tearDown() { 17 | app.shutdown() 18 | } 19 | 20 | func testResetPassword() throws { 21 | app.randomGenerators.use(.rigged(value: "passwordtoken")) 22 | 23 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 24 | try app.repositories.users.create(user).wait() 25 | 26 | let resetPasswordRequest = ResetPasswordRequest(email: "test@test.com") 27 | try app.test(.POST, "api/auth/reset-password", content: resetPasswordRequest, afterResponse: { res in 28 | XCTAssertEqual(res.status, .noContent) 29 | let passwordToken = try app.repositories.passwordTokens.find(token: SHA256.hash("passwordtoken")).wait() 30 | XCTAssertNotNil(passwordToken) 31 | 32 | let resetPasswordJob = try XCTUnwrap(app.queues.test.first(EmailJob.self)) 33 | XCTAssertEqual(resetPasswordJob.recipient, "test@test.com") 34 | XCTAssertEqual(resetPasswordJob.email.templateName, "reset_password") 35 | XCTAssertEqual(resetPasswordJob.email.templateData["reset_url"], "http://frontend.local/auth/reset-password?token=passwordtoken") 36 | }) 37 | } 38 | 39 | func testResetPasswordSucceedsWithNonExistingEmail() throws { 40 | let resetPasswordRequest = ResetPasswordRequest(email: "none@test.com") 41 | try app.test(.POST, "api/auth/reset-password", content: resetPasswordRequest, afterResponse: { res in 42 | XCTAssertEqual(res.status, .noContent) 43 | let tokenCount = try app.repositories.passwordTokens.count().wait() 44 | XCTAssertFalse(app.queues.test.contains(EmailJob.self)) 45 | XCTAssertEqual(tokenCount, 0) 46 | }) 47 | } 48 | 49 | func testRecoverAccount() throws { 50 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "oldpassword") 51 | try app.repositories.users.create(user).wait() 52 | let token = try PasswordToken(userID: user.requireID(), token: SHA256.hash("passwordtoken")) 53 | let existingToken = try PasswordToken(userID: user.requireID(), token: "token2") 54 | 55 | try app.repositories.passwordTokens.create(token).wait() 56 | try app.repositories.passwordTokens.create(existingToken).wait() 57 | 58 | let recoverRequest = RecoverAccountRequest(password: "newpassword", confirmPassword: "newpassword", token: "passwordtoken") 59 | 60 | try app.test(.POST, "api/auth/recover", content: recoverRequest, afterResponse: { res in 61 | XCTAssertEqual(res.status, .noContent) 62 | let user = try app.repositories.users.find(id: user.requireID()).wait()! 63 | try XCTAssertTrue(BCryptDigest().verify("newpassword", created: user.passwordHash)) 64 | let count = try app.repositories.passwordTokens.count().wait() 65 | XCTAssertEqual(count, 0) 66 | }) 67 | } 68 | 69 | func testRecoverAccountWithExpiredTokenFails() throws { 70 | let token = PasswordToken(userID: UUID(), token: SHA256.hash("passwordtoken"), expiresAt: Date().addingTimeInterval(-60)) 71 | try app.repositories.passwordTokens.create(token).wait() 72 | 73 | let recoverRequest = RecoverAccountRequest(password: "password", confirmPassword: "password", token: "passwordtoken") 74 | try app.test(.POST, "api/auth/recover", content: recoverRequest, afterResponse: { res in 75 | XCTAssertResponseError(res, AuthenticationError.passwordTokenHasExpired) 76 | }) 77 | } 78 | 79 | func testRecoverAccountWithInvalidTokenFails() throws { 80 | let recoverRequest = RecoverAccountRequest(password: "password", confirmPassword: "password", token: "sdfsdfsf") 81 | try app.test(.POST, "api/auth/recover", content: recoverRequest, afterResponse: { res in 82 | XCTAssertResponseError(res, AuthenticationError.invalidPasswordToken) 83 | }) 84 | } 85 | 86 | func testRecoverAccountWithNonMatchingPasswordsFail() throws { 87 | let recoverRequest = RecoverAccountRequest(password: "password", confirmPassword: "password123", token: "token") 88 | try app.test(.POST, "api/auth/recover", content: recoverRequest, afterResponse: { res in 89 | XCTAssertResponseError(res, AuthenticationError.passwordsDontMatch) 90 | }) 91 | } 92 | 93 | func testVerifyPasswordToken() throws { 94 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 95 | try app.repositories.users.create(user).wait() 96 | let passwordToken = try PasswordToken(userID: user.requireID(), token: SHA256.hash("token")) 97 | try app.repositories.passwordTokens.create(passwordToken).wait() 98 | 99 | try app.test(.GET, "api/auth/reset-password/verify?token=token", afterResponse: { res in 100 | XCTAssertEqual(res.status, .noContent) 101 | }) 102 | } 103 | 104 | func testVerifyPasswordTokenFailsWithInvalidToken() throws { 105 | try app.test(.GET, "api/auth/reset-password/verify?token=invalidtoken", afterResponse: { res in 106 | XCTAssertResponseError(res, AuthenticationError.invalidPasswordToken) 107 | }) 108 | } 109 | 110 | func testVerifyPasswordTokenFailsWithExpiredToken() throws { 111 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 112 | try app.repositories.users.create(user).wait() 113 | let passwordToken = try PasswordToken(userID: user.requireID(), token: SHA256.hash("token"), expiresAt: Date().addingTimeInterval(-60)) 114 | try app.repositories.passwordTokens.create(passwordToken).wait() 115 | 116 | try app.test(.GET, "api/auth/reset-password/verify?token=token", afterResponse: { res in 117 | XCTAssertResponseError(res, AuthenticationError.passwordTokenHasExpired) 118 | let tokenCount = try app.repositories.passwordTokens.count().wait() 119 | XCTAssertEqual(tokenCount, 0) 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Tests/AppTests/AuthenticationTests/TokenTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | import Crypto 5 | 6 | final class TokenTests: XCTestCase { 7 | var app: Application! 8 | var testWorld: TestWorld! 9 | let accessTokenPath = "api/auth/accessToken" 10 | var user: User! 11 | 12 | override func setUpWithError() throws { 13 | app = Application(.testing) 14 | try configure(app) 15 | self.testWorld = try TestWorld(app: app) 16 | 17 | user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 18 | } 19 | 20 | override func tearDown() { 21 | app.shutdown() 22 | } 23 | 24 | func testRefreshAccessToken() throws { 25 | app.randomGenerators.use(.rigged(value: "secondrefreshtoken")) 26 | 27 | try app.repositories.users.create(user).wait() 28 | 29 | let refreshToken = try RefreshToken(token: SHA256.hash("firstrefreshtoken"), userID: user.requireID()) 30 | 31 | try app.repositories.refreshTokens.create(refreshToken).wait() 32 | let tokenID = try refreshToken.requireID() 33 | 34 | let accessTokenRequest = AccessTokenRequest(refreshToken: "firstrefreshtoken") 35 | 36 | try app.test(.POST, accessTokenPath, content: accessTokenRequest, afterResponse: { res in 37 | XCTAssertEqual(res.status, .ok) 38 | XCTAssertContent(AccessTokenResponse.self, res) { response in 39 | XCTAssert(!response.accessToken.isEmpty) 40 | XCTAssertEqual(response.refreshToken, "secondrefreshtoken") 41 | } 42 | let deletedToken = try app.repositories.refreshTokens.find(id: tokenID).wait() 43 | XCTAssertNil(deletedToken) 44 | let newToken = try app.repositories.refreshTokens.find(token: SHA256.hash("secondrefreshtoken")).wait() 45 | XCTAssertNotNil(newToken) 46 | }) 47 | } 48 | 49 | func testRefreshAccessTokenFailsWithExpiredRefreshToken() throws { 50 | try app.repositories.users.create(user).wait() 51 | let token = try RefreshToken(token: SHA256.hash("123"), userID: user.requireID(), expiresAt: Date().addingTimeInterval(-60)) 52 | 53 | try app.repositories.refreshTokens.create(token).wait() 54 | 55 | let accessTokenRequest = AccessTokenRequest(refreshToken: "123") 56 | 57 | try app.test(.POST, accessTokenPath, content: accessTokenRequest, afterResponse: { res in 58 | XCTAssertResponseError(res, AuthenticationError.refreshTokenHasExpired) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/AppTests/Helpers/Application+Helpers.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import XCTVapor 3 | 4 | extension Application { 5 | // Authenticated test method 6 | @discardableResult 7 | func test( 8 | _ method: HTTPMethod, 9 | _ path: String, 10 | headers: HTTPHeaders = [:], 11 | accessToken: String? = nil, 12 | user: User? = nil, 13 | content: C, 14 | afterResponse: (XCTHTTPResponse) throws -> () = { _ in }, 15 | file: StaticString = #file, 16 | line: UInt = #line 17 | ) throws -> XCTApplicationTester { 18 | var headers = headers 19 | 20 | if let token = accessToken { 21 | headers.add(name: "Authorization", value: "Bearer \(token)") 22 | } else if let user = user { 23 | let payload = try Payload(with: user) 24 | let accessToken = try self.jwt.signers.sign(payload) 25 | 26 | headers.add(name: "Authorization", value: "Bearer \(accessToken)") 27 | } 28 | 29 | return try test(method, path, headers: headers, beforeRequest: { req in 30 | try req.content.encode(content) 31 | }, afterResponse: afterResponse) 32 | } 33 | 34 | @discardableResult 35 | func test( 36 | _ method: HTTPMethod, 37 | _ path: String, 38 | headers: HTTPHeaders = [:], 39 | user: User, 40 | afterResponse: (XCTHTTPResponse) throws -> () = { _ in }, 41 | file: StaticString = #file, 42 | line: UInt = #line 43 | ) throws -> XCTApplicationTester { 44 | let payload = try Payload(with: user) 45 | let accessToken = try self.jwt.signers.sign(payload) 46 | var headers = headers 47 | headers.add(name: "Authorization", value: "Bearer \(accessToken)") 48 | return try test(method, path, headers: headers, afterResponse: afterResponse) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/AppTests/Helpers/TestRepository.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Vapor 3 | 4 | protocol TestRepository: class { 5 | var eventLoop: EventLoop { get set } 6 | } 7 | 8 | extension TestRepository where Self: RequestService { 9 | func `for`(_ req: Request) -> Self { 10 | self.eventLoop = req.eventLoop 11 | return self 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/AppTests/Helpers/XCTAssertResponseError.swift: -------------------------------------------------------------------------------- 1 | import XCTVapor 2 | @testable import App 3 | 4 | func XCTAssertResponseError(_ res: XCTHTTPResponse, _ error: AppError, file: StaticString = #file, line: UInt = #line) { 5 | XCTAssertEqual(res.status, error.status, file: file, line: line) 6 | XCTAssertContent(ErrorResponse.self, res) { errorContent in 7 | XCTAssertEqual(errorContent.errorCode, error.identifier, file: file, line: line) 8 | XCTAssertEqual(errorContent.reason, error.reason, file: file, line: line) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Tests/AppTests/Mocks/Mailgun+Mock.swift: -------------------------------------------------------------------------------- 1 | import XCTVapor 2 | @testable import App 3 | import Mailgun 4 | 5 | struct MockMailgun: MailgunProvider { 6 | var eventLoop: EventLoop 7 | 8 | func send(_ content: MailgunMessage) -> EventLoopFuture { 9 | fatalError() 10 | } 11 | 12 | func send(_ content: MailgunTemplateMessage) -> EventLoopFuture { 13 | fatalError() 14 | } 15 | 16 | func setup(forwarding: MailgunRouteSetup) -> EventLoopFuture { 17 | fatalError() 18 | } 19 | 20 | func createTemplate(_ template: MailgunTemplate) -> EventLoopFuture { 21 | fatalError() 22 | } 23 | 24 | func delegating(to eventLoop: EventLoop) -> MailgunProvider { 25 | var copy = self 26 | copy.eventLoop = eventLoop 27 | return copy 28 | } 29 | } 30 | 31 | extension Application.Mailgun.Provider { 32 | static var fake: Self { 33 | .init { 34 | $0.mailgun.use { app, _ in 35 | MockMailgun(eventLoop: app.eventLoopGroup.next()) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/AppTests/Mocks/Repositories/TestEmailTokenRepository.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Vapor 3 | 4 | class TestEmailTokenRepository: EmailTokenRepository, TestRepository { 5 | var tokens: [EmailToken] 6 | var eventLoop: EventLoop 7 | 8 | init(tokens: [EmailToken] = [], eventLoop: EventLoop) { 9 | self.tokens = tokens 10 | self.eventLoop = eventLoop 11 | } 12 | 13 | func find(token: String) -> EventLoopFuture { 14 | let token = tokens.first(where: { $0.token == token }) 15 | return eventLoop.makeSucceededFuture(token) 16 | } 17 | 18 | func create(_ emailToken: EmailToken) -> EventLoopFuture { 19 | tokens.append(emailToken) 20 | return eventLoop.makeSucceededFuture(()) 21 | } 22 | 23 | func delete(_ emailToken: EmailToken) -> EventLoopFuture { 24 | tokens.removeAll(where: { $0.id == emailToken.id }) 25 | return eventLoop.makeSucceededFuture(()) 26 | } 27 | 28 | 29 | func find(userID: UUID) -> EventLoopFuture { 30 | let token = tokens.first(where: { $0.$user.id == userID }) 31 | return eventLoop.makeSucceededFuture(token) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/AppTests/Mocks/Repositories/TestPasswordTokenRepository.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Vapor 3 | 4 | final class TestPasswordTokenRepository: PasswordTokenRepository, TestRepository { 5 | var eventLoop: EventLoop 6 | var tokens: [PasswordToken] 7 | 8 | init(tokens: [PasswordToken], eventLoop: EventLoop) { 9 | self.eventLoop = eventLoop 10 | self.tokens = tokens 11 | } 12 | 13 | func find(userID: UUID) -> EventLoopFuture { 14 | let token = tokens.first(where: { $0.$user.id == userID }) 15 | return eventLoop.makeSucceededFuture(token) 16 | } 17 | 18 | func find(token: String) -> EventLoopFuture { 19 | let token = tokens.first(where: { $0.token == token }) 20 | return eventLoop.makeSucceededFuture(token) 21 | } 22 | 23 | func count() -> EventLoopFuture { 24 | return eventLoop.makeSucceededFuture(tokens.count) 25 | } 26 | 27 | func create(_ passwordToken: PasswordToken) -> EventLoopFuture { 28 | tokens.append(passwordToken) 29 | return eventLoop.makeSucceededFuture(()) 30 | } 31 | 32 | 33 | func delete(_ passwordToken: PasswordToken) -> EventLoopFuture { 34 | tokens.removeAll(where: { passwordToken.id == $0.id }) 35 | return eventLoop.makeSucceededFuture(()) 36 | } 37 | 38 | func delete(for userID: UUID) -> EventLoopFuture { 39 | tokens.removeAll(where: { $0.$user.id == userID }) 40 | return eventLoop.makeSucceededFuture(()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/AppTests/Mocks/Repositories/TestRefreshTokenRepository.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Vapor 3 | import Crypto 4 | 5 | class TestRefreshTokenRepository: RefreshTokenRepository, TestRepository { 6 | var tokens: [RefreshToken] 7 | var eventLoop: EventLoop 8 | 9 | init(tokens: [RefreshToken] = [], eventLoop: EventLoop) { 10 | self.tokens = tokens 11 | self.eventLoop = eventLoop 12 | } 13 | 14 | func create(_ token: RefreshToken) -> EventLoopFuture { 15 | token.id = UUID() 16 | tokens.append(token) 17 | return eventLoop.makeSucceededFuture(()) 18 | } 19 | 20 | func find(id: UUID?) -> EventLoopFuture { 21 | let token = tokens.first(where: { $0.id == id}) 22 | return eventLoop.makeSucceededFuture(token) 23 | } 24 | 25 | func find(token: String) -> EventLoopFuture { 26 | let token = tokens.first(where: { $0.token == token }) 27 | return eventLoop.makeSucceededFuture(token) 28 | } 29 | 30 | func delete(_ token: RefreshToken) -> EventLoopFuture { 31 | tokens.removeAll(where: { $0.id == token.id }) 32 | return eventLoop.makeSucceededFuture(()) 33 | } 34 | 35 | func count() -> EventLoopFuture { 36 | return eventLoop.makeSucceededFuture(tokens.count) 37 | } 38 | 39 | func delete(for userID: UUID) -> EventLoopFuture { 40 | tokens.removeAll(where: { $0.$user.id == userID }) 41 | return eventLoop.makeSucceededFuture(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/AppTests/Mocks/Repositories/TestUserRepository.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Vapor 3 | import Fluent 4 | 5 | class TestUserRepository: UserRepository, TestRepository { 6 | var users: [User] 7 | var eventLoop: EventLoop 8 | 9 | init(users: [User] = [User](), eventLoop: EventLoop) { 10 | self.users = users 11 | self.eventLoop = eventLoop 12 | } 13 | 14 | func create(_ user: User) -> EventLoopFuture { 15 | user.id = UUID() 16 | users.append(user) 17 | return eventLoop.makeSucceededFuture(()) 18 | } 19 | 20 | func delete(id: UUID) -> EventLoopFuture { 21 | users.removeAll(where: { $0.id == id }) 22 | return eventLoop.makeSucceededFuture(()) 23 | } 24 | 25 | func all() -> EventLoopFuture<[User]> { 26 | return eventLoop.makeSucceededFuture(users) 27 | } 28 | 29 | func find(id: UUID?) -> EventLoopFuture { 30 | let user = users.first(where: { $0.id == id }) 31 | return eventLoop.makeSucceededFuture(user) 32 | } 33 | 34 | func find(email: String) -> EventLoopFuture { 35 | let user = users.first(where: { $0.email == email }) 36 | return eventLoop.makeSucceededFuture(user) 37 | } 38 | 39 | func set(_ field: KeyPath, to value: Field.Value, for userID: UUID) -> EventLoopFuture where Field : QueryableProperty, Field.Model == User { 40 | let user = users.first(where: { $0.id == userID })! 41 | user[keyPath: field].value = value 42 | return eventLoop.makeSucceededFuture(()) 43 | } 44 | 45 | func count() -> EventLoopFuture { 46 | return eventLoop.makeSucceededFuture(users.count) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/AppTests/Mocks/RiggedRandomGenerator.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Vapor 3 | 4 | 5 | extension Application.RandomGenerators.Provider { 6 | static func rigged(value: String) -> Self { 7 | .init { 8 | $0.randomGenerators.use { _ in RiggedRandomGenerator(value: value) } } 9 | } 10 | } 11 | 12 | struct RiggedRandomGenerator: RandomGenerator { 13 | let value: String 14 | 15 | func generate(bits: Int) -> String { 16 | return value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/AppTests/RepositoryTests/EmailTokenRepostitoryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | 5 | final class EmailTokenRepositoryTests: XCTestCase { 6 | var app: Application! 7 | var repository: EmailTokenRepository! 8 | var user: User! 9 | 10 | override func setUpWithError() throws { 11 | app = Application(.testing) 12 | try configure(app) 13 | repository = DatabaseEmailTokenRepository(database: app.db) 14 | try app.autoMigrate().wait() 15 | 16 | user = User(fullName: "Test", email: "test@test.com", passwordHash: "123") 17 | } 18 | 19 | override func tearDownWithError() throws { 20 | try app.autoRevert().wait() 21 | app.shutdown() 22 | } 23 | 24 | func testCreatingEmailToken() throws { 25 | try user.create(on: app.db).wait() 26 | let emailToken = EmailToken(userID: try user.requireID(), token: "emailToken") 27 | try repository.create(emailToken).wait() 28 | 29 | let count = try EmailToken.query(on: app.db).count().wait() 30 | XCTAssertEqual(count, 1) 31 | } 32 | 33 | func testFindingEmailTokenByToken() throws { 34 | try user.create(on: app.db).wait() 35 | let emailToken = EmailToken(userID: try user.requireID(), token: "123") 36 | try emailToken.create(on: app.db).wait() 37 | let found = try repository.find(token: "123").wait() 38 | XCTAssertNotNil(found) 39 | } 40 | 41 | func testDeleteEmailToken() throws { 42 | try user.create(on: app.db).wait() 43 | let emailToken = EmailToken(userID: try user.requireID(), token: "123") 44 | try emailToken.create(on: app.db).wait() 45 | try repository.delete(emailToken).wait() 46 | let count = try EmailToken.query(on: app.db).count().wait() 47 | XCTAssertEqual(count, 0) 48 | } 49 | } 50 | 51 | 52 | -------------------------------------------------------------------------------- /Tests/AppTests/RepositoryTests/PasswordTokenRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | 5 | final class PasswordTokenRepositoryTests: XCTestCase { 6 | var app: Application! 7 | var repository: PasswordTokenRepository! 8 | var user: User! 9 | 10 | override func setUpWithError() throws { 11 | app = Application(.testing) 12 | try configure(app) 13 | repository = DatabasePasswordTokenRepository(database: app.db) 14 | try app.autoMigrate().wait() 15 | user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 16 | try user.create(on: app.db).wait() 17 | } 18 | 19 | override func tearDownWithError() throws { 20 | try app.migrator.revertAllBatches().wait() 21 | app.shutdown() 22 | } 23 | 24 | func testFindByUserID() throws { 25 | let userID = try user.requireID() 26 | let token = PasswordToken(userID: userID, token: "123") 27 | try token.create(on: app.db).wait() 28 | try XCTAssertNotNil(repository.find(userID: userID).wait()) 29 | } 30 | 31 | func testFindByToken() throws { 32 | let token = PasswordToken(userID: try user.requireID(), token: "token123") 33 | try token.create(on: app.db).wait() 34 | try XCTAssertNotNil(repository.find(token: "token123").wait()) 35 | } 36 | 37 | func testCount() throws { 38 | let token = PasswordToken(userID: try user.requireID(), token: "token123") 39 | let token2 = PasswordToken(userID: try user.requireID(), token: "token123") 40 | try [token, token2].create(on: app.db).wait() 41 | let count = try repository.count().wait() 42 | XCTAssertEqual(count, 2) 43 | } 44 | 45 | func testCreate() throws { 46 | let token = PasswordToken(userID: try user.requireID(), token: "token123") 47 | try repository.create(token).wait() 48 | try XCTAssertNotNil(PasswordToken.find(try token.requireID(), on: app.db).wait()) 49 | } 50 | 51 | func testDelete() throws { 52 | let token = PasswordToken(userID: try user.requireID(), token: "token123") 53 | try token.create(on: app.db).wait() 54 | try repository.delete(token).wait() 55 | let count = try PasswordToken.query(on: app.db).count().wait() 56 | XCTAssertEqual(count, 0) 57 | } 58 | 59 | } 60 | 61 | 62 | -------------------------------------------------------------------------------- /Tests/AppTests/RepositoryTests/RefreshTokenRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | 5 | final class RefreshTokenRepositoryTests: XCTestCase { 6 | var app: Application! 7 | var repository: RefreshTokenRepository! 8 | var user: User! 9 | 10 | override func setUpWithError() throws { 11 | app = Application(.testing) 12 | try configure(app) 13 | repository = DatabaseRefreshTokenRepository(database: app.db) 14 | try app.autoMigrate().wait() 15 | user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | try app.migrator.revertAllBatches().wait() 20 | app.shutdown() 21 | } 22 | 23 | func testCreatingToken() throws { 24 | try user.create(on: app.db).wait() 25 | let token = try RefreshToken(token: "123", userID: user.requireID()) 26 | try repository.create(token).wait() 27 | 28 | XCTAssertNotNil(token.id) 29 | 30 | let tokenRetrieved = try RefreshToken.find(token.id, on: app.db).wait() 31 | XCTAssertNotNil(tokenRetrieved) 32 | XCTAssertEqual(tokenRetrieved!.$user.id, try user.requireID()) 33 | } 34 | 35 | func testFindingTokenById() throws { 36 | try user.create(on: app.db).wait() 37 | let token = try RefreshToken(token: "123", userID: user.requireID()) 38 | try token.create(on: app.db).wait() 39 | let tokenId = try token.requireID() 40 | let tokenFound = try repository.find(id: tokenId).wait() 41 | XCTAssertNotNil(tokenFound) 42 | } 43 | 44 | // TODO: Requires to reset the middleware of the database... so lets do that when my PR gets merged. 45 | func testFindingTokenByTokenString() throws { 46 | try user.create(on: app.db).wait() 47 | let token = try RefreshToken(token: "123", userID: user.requireID()) 48 | try token.create(on: app.db).wait() 49 | let tokenFound = try repository.find(token: "123").wait() 50 | XCTAssertNotNil(tokenFound) 51 | } 52 | 53 | func testDeletingToken() throws { 54 | try user.create(on: app.db).wait() 55 | let token = try RefreshToken(token: "123", userID: user.requireID()) 56 | try token.create(on: app.db).wait() 57 | let tokenCount = try RefreshToken.query(on: app.db).count().wait() 58 | XCTAssertEqual(tokenCount, 1) 59 | try repository.delete(token).wait() 60 | let newTokenCount = try RefreshToken.query(on: app.db).count().wait() 61 | XCTAssertEqual(newTokenCount, 0) 62 | } 63 | 64 | func testGetCount() throws { 65 | try user.create(on: app.db).wait() 66 | let token = try RefreshToken(token: "123", userID: user.requireID()) 67 | try token.create(on: app.db).wait() 68 | let tokenCount = try repository.count().wait() 69 | XCTAssertEqual(tokenCount, 1) 70 | } 71 | } 72 | 73 | 74 | -------------------------------------------------------------------------------- /Tests/AppTests/RepositoryTests/UserRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | 5 | final class UserRepositoryTests: XCTestCase { 6 | var app: Application! 7 | var repository: UserRepository! 8 | 9 | override func setUpWithError() throws { 10 | app = Application(.testing) 11 | try configure(app) 12 | repository = DatabaseUserRepository(database: app.db) 13 | try app.autoMigrate().wait() 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | try app.autoRevert().wait() 18 | app.shutdown() 19 | } 20 | 21 | func testCreatingUser() throws { 22 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 23 | try repository.create(user).wait() 24 | 25 | XCTAssertNotNil(user.id) 26 | 27 | let userRetrieved = try User.find(user.id, on: app.db).wait() 28 | XCTAssertNotNil(userRetrieved) 29 | } 30 | 31 | func testDeletingUser() throws { 32 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 33 | try user.create(on: app.db).wait() 34 | let count = try User.query(on: app.db).count().wait() 35 | XCTAssertEqual(count, 1) 36 | 37 | try repository.delete(id: user.requireID()).wait() 38 | let countAfterDelete = try User.query(on: app.db).count().wait() 39 | XCTAssertEqual(countAfterDelete, 0) 40 | } 41 | 42 | func testGetAllUsers() throws { 43 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 44 | let user2 = User(fullName: "Test User 2", email: "test2@test.com", passwordHash: "123") 45 | 46 | try user.create(on: app.db).wait() 47 | try user2.create(on: app.db).wait() 48 | 49 | let users = try repository.all().wait() 50 | XCTAssertEqual(users.count, 2) 51 | } 52 | 53 | func testFindUserById() throws { 54 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123") 55 | try user.create(on: app.db).wait() 56 | 57 | let userFound = try repository.find(id: user.requireID()).wait() 58 | XCTAssertNotNil(userFound) 59 | } 60 | 61 | func testSetFieldValue() throws { 62 | let user = User(fullName: "Test User", email: "test@test.com", passwordHash: "123", isEmailVerified: false) 63 | try user.create(on: app.db).wait() 64 | 65 | try repository.set(\.$isEmailVerified, to: true, for: user.requireID()).wait() 66 | 67 | let updatedUser = try User.find(user.id!, on: app.db).wait() 68 | XCTAssertEqual(updatedUser!.isEmailVerified, true) 69 | } 70 | } 71 | 72 | 73 | -------------------------------------------------------------------------------- /Tests/AppTests/ServiceTests/RandomGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import XCTVapor 3 | 4 | final class RandomGeneratorTests: XCTestCase { 5 | var app: Application! 6 | var testWorld: TestWorld! 7 | 8 | override func setUpWithError() throws { 9 | app = Application(.testing) 10 | try configure(app) 11 | testWorld = try .init(app: app) 12 | } 13 | 14 | override func tearDown() { 15 | app.shutdown() 16 | } 17 | 18 | func testDefaultProvider() throws { 19 | let defaultGenerator = app.random.generator 20 | XCTAssertTrue(type(of: defaultGenerator) == RealRandomGenerator.self) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/AppTests/TestWorld.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import Fluent 3 | import XCTVapor 4 | import XCTQueues 5 | 6 | class TestWorld { 7 | let app: Application 8 | 9 | // Repositories 10 | private var tokenRepository: TestRefreshTokenRepository 11 | private var userRepository: TestUserRepository 12 | private var emailTokenRepository: TestEmailTokenRepository 13 | private var passwordTokenRepository: TestPasswordTokenRepository 14 | 15 | private var refreshtokens: [RefreshToken] = [] 16 | private var users: [User] = [] 17 | private var emailTokens: [EmailToken] = [] 18 | private var passwordTokens: [PasswordToken] = [] 19 | 20 | init(app: Application) throws { 21 | self.app = app 22 | 23 | try app.jwt.signers.use(.es256(key: .generate())) 24 | 25 | self.tokenRepository = TestRefreshTokenRepository(tokens: refreshtokens, eventLoop: app.eventLoopGroup.next()) 26 | self.userRepository = TestUserRepository(users: users, eventLoop: app.eventLoopGroup.next()) 27 | self.emailTokenRepository = TestEmailTokenRepository(tokens: emailTokens, eventLoop: app.eventLoopGroup.next()) 28 | self.passwordTokenRepository = TestPasswordTokenRepository(tokens: passwordTokens, eventLoop: app.eventLoopGroup.next()) 29 | 30 | app.repositories.use { _ in self.tokenRepository } 31 | app.repositories.use { _ in self.userRepository } 32 | app.repositories.use { _ in self.emailTokenRepository } 33 | app.repositories.use { _ in self.passwordTokenRepository } 34 | 35 | app.queues.use(.test) 36 | app.mailgun.use(.fake) 37 | app.config = .init(frontendURL: "http://frontend.local", apiURL: "http://api.local", noReplyEmail: "no-reply@testing.local") 38 | } 39 | } 40 | 41 | 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | db: 5 | image: postgres:11 6 | environment: 7 | POSTGRES_USER: vapor 8 | POSTGRES_DB: vapor 9 | POSTGRES_PASSWORD: password 10 | ports: 11 | - 5432:5432 12 | redis: 13 | image: 'bitnami/redis:5.0' 14 | environment: 15 | # ALLOW_EMPTY_PASSWORD is recommended only for development. 16 | - ALLOW_EMPTY_PASSWORD=yes 17 | - REDIS_DISABLE_COMMANDS=FLUSHDB,FLUSHALL 18 | ports: 19 | - '6379:6379' 20 | -------------------------------------------------------------------------------- /web.Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM vapor/swift:5.2 as build 5 | WORKDIR /build 6 | 7 | # Copy entire repo into container 8 | COPY . . 9 | 10 | # Compile with optimizations 11 | RUN swift build \ 12 | --enable-test-discovery \ 13 | -c release \ 14 | -Xswiftc -g 15 | 16 | # ================================ 17 | # Run image 18 | # ================================ 19 | FROM vapor/ubuntu:18.04 20 | WORKDIR /run 21 | 22 | # Copy build artifacts 23 | COPY --from=build /build/.build/release /run 24 | # Copy Swift runtime libraries 25 | COPY --from=build /usr/lib/swift/ /usr/lib/swift/ 26 | 27 | ENTRYPOINT ["./Run", "serve", "--env", "production", "--hostname", "0.0.0.0"] 28 | --------------------------------------------------------------------------------