├── .codebeatignore ├── .codecov.yml ├── .gitignore ├── Tests ├── LinuxMain.swift └── ResetTests │ └── ResetTests.swift ├── .swiftlint.yml ├── Resources └── Views │ └── Reset │ ├── Password │ ├── reset-password-success.leaf │ ├── reset-password-request-success.leaf │ ├── reset-password-request-form.leaf │ └── reset-password-form.leaf │ └── Layout │ └── base.leaf ├── Sources └── Reset │ ├── PasswordChangeCountClaim.swift │ ├── ResetError.swift │ ├── Config │ ├── ResetEndpoints.swift │ ├── ResetConfig.swift │ └── ResetResponses.swift │ ├── Tags │ └── ResetConfigTag.swift │ ├── Provider │ └── ResetProvider.swift │ ├── Command │ └── GeneratePasswordResetTokenCommand.swift │ ├── PasswordResettable.swift │ └── Controllers │ └── ResetController.swift ├── Public └── Reset │ └── css │ └── reset.css ├── .github └── workflows │ ├── documentation.yml │ └── test.yml ├── LICENSE ├── Package.swift └── README.md /.codebeatignore: -------------------------------------------------------------------------------- 1 | Public/** 2 | Resources/Assets/** -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "0...100" 3 | ignore: 4 | - "Tests" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .idea 4 | .DS_Store 5 | *.xcodeproj 6 | DerivedData/ 7 | Package.resolved 8 | .swiftpm 9 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ResetTests 3 | 4 | XCTMain([ 5 | testCase(ResetTests.allTests), 6 | ]) 7 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | function_body_length: 4 | warning: 60 5 | identifier_name: 6 | min_length: 7 | warning: 2 8 | line_length: 100 9 | disabled_rules: 10 | - opening_brace 11 | colon: 12 | flexible_right_spacing: true 13 | -------------------------------------------------------------------------------- /Resources/Views/Reset/Password/reset-password-success.leaf: -------------------------------------------------------------------------------- 1 | #set("pageTitle") { Password updated } 2 | 3 | #set("body") { 4 |
5 |

#reset:config("name")

6 |

Reset password

7 |

Your password has been updated.

8 |
9 | } 10 | 11 | #embed("Reset/Layout/base") 12 | -------------------------------------------------------------------------------- /Resources/Views/Reset/Password/reset-password-request-success.leaf: -------------------------------------------------------------------------------- 1 | #set("pageTitle") { Link sent } 2 | 3 | #set("body") { 4 |
5 |

#reset:config("name")

6 |

Reset password

7 |

We have sent you a link to reset your password.

8 |
9 | } 10 | 11 | #embed("Reset/Layout/base") 12 | -------------------------------------------------------------------------------- /Tests/ResetTests/ResetTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import HTTP 3 | @testable import Reset 4 | 5 | class ResetTests: XCTestCase { 6 | func testExample() throws { 7 | XCTAssertTrue(true) 8 | } 9 | 10 | static var allTests : [(String, (ResetTests) -> () throws -> Void)] { 11 | return [ 12 | ("testExample", testExample), 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Reset/PasswordChangeCountClaim.swift: -------------------------------------------------------------------------------- 1 | import JWT 2 | 3 | /// Identifies the number of times a user's password has been changed 4 | /// - id: pcc 5 | public struct PasswordChangeCountClaim: JWTClaim { 6 | /// The number of times the password has been changed 7 | public var value: Int 8 | 9 | /// See `Claim` 10 | public init(value: Int) { 11 | self.value = value 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Public/Reset/css/reset.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | display: -ms-flexbox; 8 | display: flex; 9 | -ms-flex-align: center; 10 | align-items: center; 11 | padding-top: 40px; 12 | padding-bottom: 40px; 13 | background-color: #f5f5f5; 14 | } 15 | 16 | .reset-password { 17 | width: 100%; 18 | max-width: 500px; 19 | padding: 15px; 20 | margin: auto; 21 | } 22 | .reset-password .form-control { 23 | position: relative; 24 | box-sizing: border-box; 25 | height: auto; 26 | padding: 10px; 27 | font-size: 16px; 28 | } 29 | .reset-password .form-control:focus { 30 | z-index: 2; 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: [release] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Generate Documentation 12 | uses: SwiftDocOrg/swift-doc@master 13 | with: 14 | inputs: "Sources/Reset" 15 | module-name: Reset 16 | output: "Documentation" 17 | - name: Upload Documentation to Wiki 18 | uses: SwiftDocOrg/github-wiki-publish-action@master@v1 19 | with: 20 | path: "Documentation" 21 | env: 22 | GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.WIKI_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /Resources/Views/Reset/Password/reset-password-request-form.leaf: -------------------------------------------------------------------------------- 1 | #set("pageTitle") { Request password reset } 2 | 3 | #set("body") { 4 |
5 |
6 |

#reset:config("name")

7 |

Reset password

8 |

Please fill out your username to receive a link to reset your password.

9 | 10 |
11 | 12 | 13 |
14 | 15 | 16 |
17 |
18 | } 19 | 20 | #embed("Reset/Layout/base") 21 | -------------------------------------------------------------------------------- /Resources/Views/Reset/Layout/base.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | #get(pageTitle) 11 | 12 | 13 | 14 | 15 | 16 | 17 | #get(styles) 18 | 19 | 20 | 21 | #get(body) 22 | #get(javascript) 23 | 24 | -------------------------------------------------------------------------------- /Sources/Reset/ResetError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public enum ResetError: Error { 4 | case tokenAlreadyUsed 5 | case userNotFound 6 | } 7 | 8 | // MARK: - AbortError 9 | extension ResetError: AbortError { 10 | public var status: HTTPResponseStatus { 11 | switch self { 12 | case .tokenAlreadyUsed : return .forbidden 13 | case .userNotFound : return .notFound 14 | } 15 | } 16 | 17 | public var reason: String { 18 | switch self { 19 | case .tokenAlreadyUsed : return "A password reset token can only be used once." 20 | case .userNotFound : return "Could not find user." 21 | } 22 | } 23 | } 24 | 25 | // MARK: - Debuggable 26 | extension ResetError: Debuggable { 27 | public var identifier: String { 28 | switch self { 29 | case .tokenAlreadyUsed : return "tokenAlreadyUsed" 30 | case .userNotFound : return "userNotFound" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | macos: 9 | runs-on: macos-latest 10 | env: 11 | DEVELOPER_DIR: /Applications/Xcode_12.app/Contents/Developer 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: brew install libressl 15 | - run: xcrun swift test --enable-test-discovery --sanitize=thread -Xcxx "-I/usr/local/opt/openssl/include" -Xlinker "-L/usr/local/opt/openssl/lib" 16 | linux-4_2: 17 | runs-on: ubuntu-latest 18 | container: 19 | image: swift:4.2 20 | steps: 21 | - uses: actions/checkout@v2 22 | - run: swift test 23 | linux: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | image: 29 | - swift:5.2-focal 30 | - swift:5.3-focal 31 | container: ${{ matrix.image }} 32 | steps: 33 | - uses: actions/checkout@v2 34 | - run: apt-get -qq update && apt-get install -y libssl-dev 35 | - run: swift test --enable-test-discovery --sanitize=thread 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nodes 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. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Reset", 6 | products: [ 7 | .library(name: "Reset", targets: ["Reset"]), 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/nodes-vapor/submissions.git", from: "2.0.0"), 11 | .package(url: "https://github.com/nodes-vapor/sugar.git", from: "4.0.0"), 12 | .package(url: "https://github.com/vapor/auth.git", from: "2.0.0"), 13 | .package(url: "https://github.com/vapor/fluent.git", from: "3.0.0"), 14 | .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"), 15 | .package(url: "https://github.com/vapor/leaf.git", from: "3.0.0"), 16 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0") 17 | ], 18 | targets: [ 19 | .target(name: "Reset", dependencies: [ 20 | "Authentication", 21 | "Fluent", 22 | "JWT", 23 | "Leaf", 24 | "Sugar", 25 | "Submissions", 26 | "Vapor" 27 | ]), 28 | .testTarget(name: "ResetTests", dependencies: ["Reset"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /Sources/Reset/Config/ResetEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Routing 2 | import Vapor 3 | 4 | public struct ResetEndpoints { 5 | public let renderResetPasswordRequest: String? 6 | public let resetPasswordRequest: String? 7 | public let renderResetPassword: String? 8 | public let resetPassword: String? 9 | 10 | /// Endpoints to use by provider when registering routes. 11 | /// 12 | /// - Parameters: 13 | /// - renderResetPasswordRequest: reset password request form endpoint (GET). 14 | /// - resetPasswordRequest: reset password request endpoint (POST). 15 | /// - renderResetPassword: reset password form endpoint (GET). 16 | /// - resetPassword: reset password endpoint (POST). 17 | public init( 18 | renderResetPasswordRequest: String? = "/users/reset-password/request", 19 | resetPasswordRequest: String? = "/users/reset-password/request", 20 | renderResetPassword: String? = "/users/reset-password", 21 | resetPassword: String? = "/users/reset-password" 22 | ) { 23 | self.renderResetPasswordRequest = renderResetPasswordRequest 24 | self.resetPasswordRequest = resetPasswordRequest 25 | self.renderResetPassword = renderResetPassword 26 | self.resetPassword = resetPassword 27 | } 28 | 29 | public static var `default`: ResetEndpoints { 30 | return .init() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Reset/Tags/ResetConfigTag.swift: -------------------------------------------------------------------------------- 1 | import Async 2 | import Leaf 3 | import TemplateKit 4 | 5 | public final class ResetConfigTag: TagRenderer { 6 | public func render(tag: TagContext) throws -> Future { 7 | try tag.requireParameterCount(1) 8 | let config = try tag.container.make(ResetConfigTagData.self) 9 | return try tag.future(config.viewData(for: tag.parameters[0], tag: tag)) 10 | } 11 | 12 | public init() {} 13 | } 14 | 15 | public final class ResetConfigTagData: Service { 16 | enum Keys: String { 17 | case name 18 | case baseURL 19 | } 20 | 21 | public let name: String 22 | public let baseURL: String 23 | 24 | init(name: String, baseURL: String) { 25 | self.name = name 26 | self.baseURL = baseURL 27 | } 28 | 29 | func viewData(for data: TemplateData, tag: TagContext) throws -> TemplateData { 30 | guard let key = data.string else { 31 | throw tag.error(reason: "Wrong type given (expected a string): \(type(of: data))") 32 | } 33 | 34 | guard let parsedKey = Keys(rawValue: key) else { 35 | throw tag.error(reason: "Wrong argument given: \(key)") 36 | } 37 | 38 | switch parsedKey { 39 | case .name: 40 | return .string(name) 41 | case .baseURL: 42 | return .string(baseURL) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Resources/Views/Reset/Password/reset-password-form.leaf: -------------------------------------------------------------------------------- 1 | #set("pageTitle") { Reset your password} 2 | 3 | #set("body") { 4 |
5 |
6 |

#reset:config("name")

7 |

Reset password

8 | 9 |
10 | 11 | 12 |
13 | 14 |
15 | 16 | 17 |
18 | 19 | 20 |
21 |
22 | } 23 | 24 | #set("javascript") { 25 | 40 | } 41 | 42 | #embed("Reset/Layout/base") 43 | -------------------------------------------------------------------------------- /Sources/Reset/Config/ResetConfig.swift: -------------------------------------------------------------------------------- 1 | import JWT 2 | import Service 3 | import Sugar 4 | import Vapor 5 | 6 | public struct ResetConfig: Service { 7 | public let baseURL: String 8 | public let controller: ResetControllerType 9 | public let endpoints: ResetEndpoints 10 | public let name: String 11 | public let responses: ResetResponses 12 | public let signer: JWTSigner 13 | 14 | /// Creates a new PasswordReset configuration. 15 | /// 16 | /// - Parameters: 17 | /// - name: the name of your project. 18 | /// - baseURL: the base url of your project. Used for generating reset password links. 19 | /// - endpoints: determines the endpoints for the routes 20 | /// - signer: signer and expiration period for password reset tokens. 21 | /// - responses: contains the responses to be returned when requesting the endpoints. 22 | /// - controller: contains logic for handling the reset password flow. 23 | public init( 24 | name: String, 25 | baseURL: String, 26 | endpoints: ResetEndpoints = .default, 27 | signer: JWTSigner, 28 | responses: ResetResponses = .default, 29 | controller: ResetControllerType = ResetController() 30 | ) { 31 | self.baseURL = baseURL 32 | self.controller = controller 33 | self.endpoints = endpoints 34 | self.name = name 35 | self.responses = responses 36 | self.signer = signer 37 | } 38 | } 39 | 40 | // MAKR: - Helpers 41 | 42 | public extension ResetConfig { 43 | func reset( 44 | _ object: T, 45 | context: T.Context, 46 | on req: Request 47 | ) throws -> Future { 48 | let expirationPeriod = T.expirationPeriod(for: context) 49 | let expirableSigner = ExpireableJWTSigner( 50 | expirationPeriod: expirationPeriod, 51 | signer: signer 52 | ) 53 | return try object 54 | .signToken(using: expirableSigner, on: req) 55 | .flatMap(to: Void.self) { token in 56 | let url = self.baseURL 57 | .appending("\(self.endpoints.renderResetPassword ?? "")/\(token)") 58 | return try object.sendPasswordReset( 59 | url: url, 60 | token: token, 61 | expirationPeriod: expirationPeriod, 62 | context: context, 63 | on: req 64 | ) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Reset/Provider/ResetProvider.swift: -------------------------------------------------------------------------------- 1 | import Authentication 2 | import Fluent 3 | import JWT 4 | import Leaf 5 | import Sugar 6 | import Vapor 7 | 8 | public final class ResetProvider { 9 | private let configFactory: (Container) throws -> ResetConfig 10 | 11 | public init(configFactory: @escaping (Container) throws -> ResetConfig) { 12 | self.configFactory = configFactory 13 | } 14 | } 15 | 16 | // MARK: - Provider 17 | 18 | extension ResetProvider: Provider { 19 | public func register(_ services: inout Services) throws { 20 | services.register(factory: configFactory) 21 | 22 | services.register { container -> ResetConfigTagData in 23 | let config: ResetConfig = try container.make() 24 | return ResetConfigTagData(name: config.name, baseURL: config.baseURL) 25 | } 26 | } 27 | 28 | public func didBoot(_ container: Container) throws -> Future { 29 | return .done(on: container) 30 | } 31 | } 32 | 33 | // MARK: - Commands 34 | 35 | extension ResetProvider where U.Database: QuerySupporting, U.ID: LosslessStringConvertible { 36 | public static func commands( 37 | databaseIdentifier: DatabaseIdentifier 38 | ) -> [String: Command] { 39 | return [ 40 | "reset:generate-token": GeneratePasswordResetTokenCommand( 41 | databaseIdentifier: databaseIdentifier 42 | ) 43 | ] 44 | } 45 | } 46 | 47 | // MARK: - Routes 48 | 49 | public extension Router { 50 | func useResetRoutes( 51 | _ type: U.Type, 52 | on container: Container 53 | ) throws { 54 | let config: ResetConfig = try container.make() 55 | let endpoints = config.endpoints 56 | let controller = config.controller 57 | 58 | if let renderResetPasswordRequestPath = endpoints.renderResetPasswordRequest { 59 | get(renderResetPasswordRequestPath) { req in try controller.renderResetPasswordRequestForm(req) } 60 | } 61 | 62 | if let resetPasswordRequestPath = endpoints.resetPasswordRequest { 63 | post(resetPasswordRequestPath) { req in try controller.resetPasswordRequest(req) } 64 | } 65 | 66 | if let renderResetPasswordPath = endpoints.renderResetPassword { 67 | get(renderResetPasswordPath, String.parameter) { req in try controller.renderResetPasswordForm(req) } 68 | } 69 | 70 | if let resetPasswordPath = endpoints.resetPassword { 71 | post(resetPasswordPath, String.parameter) { req in try controller.resetPassword(req) } 72 | } 73 | } 74 | } 75 | 76 | // MARK: Leaf tags 77 | 78 | public extension LeafTagConfig { 79 | mutating func useResetLeafTags() { 80 | use(ResetConfigTag(), as: "reset:config") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Reset/Config/ResetResponses.swift: -------------------------------------------------------------------------------- 1 | import Leaf 2 | import Sugar 3 | import Vapor 4 | 5 | public struct ResetResponses { 6 | private enum ViewPaths: String { 7 | case resetPasswordRequestForm = "Reset/Password/reset-password-request-form" 8 | case resetPasswordUserNotified = "Reset/Password/reset-password-request-success" 9 | case resetPasswordForm = "Reset/Password/reset-password-form" 10 | case resetPasswordSuccess = "Reset/Password/reset-password-success" 11 | } 12 | 13 | public let resetPasswordRequestForm: (Request) throws -> Future 14 | public let resetPasswordUserNotified: (Request) throws -> Future 15 | public let resetPasswordForm: (Request, U) throws -> Future 16 | public let resetPasswordSuccess: (Request, U) throws -> Future 17 | 18 | public init( 19 | resetPasswordRequestForm: @escaping (Request) throws -> Future, 20 | resetPasswordUserNotified: @escaping (Request) throws -> Future, 21 | resetPasswordForm: @escaping (Request, U) throws -> Future, 22 | resetPasswordSuccess: @escaping (Request, U) throws -> Future 23 | ) { 24 | self.resetPasswordRequestForm = resetPasswordRequestForm 25 | self.resetPasswordUserNotified = resetPasswordUserNotified 26 | self.resetPasswordForm = resetPasswordForm 27 | self.resetPasswordSuccess = resetPasswordSuccess 28 | } 29 | 30 | public static var `default`: ResetResponses { 31 | return .init( 32 | resetPasswordRequestForm: { req in 33 | try req 34 | .view() 35 | .render(ViewPaths.resetPasswordRequestForm.rawValue, on: req) 36 | .encode(for: req) 37 | }, 38 | resetPasswordUserNotified: { req in 39 | guard 40 | req.http.accept.comparePreference(for: .html, to: .json) == .orderedAscending 41 | else { 42 | return try HTTPResponse(status: .ok).encode(for: req) 43 | } 44 | 45 | return try req 46 | .view() 47 | .render(ViewPaths.resetPasswordUserNotified.rawValue, on: req) 48 | .encode(for: req) 49 | }, 50 | resetPasswordForm: { req, user in 51 | try req 52 | .make(LeafRenderer.self) 53 | .render(ViewPaths.resetPasswordForm.rawValue) 54 | .encode(for: req) 55 | }, 56 | resetPasswordSuccess: { req, user in 57 | guard 58 | req.http.accept.comparePreference(for: .html, to: .json) == .orderedAscending 59 | else { 60 | return try HTTPResponse(status: .ok).encode(for: req) 61 | } 62 | 63 | return try req 64 | .view() 65 | .render(ViewPaths.resetPasswordSuccess.rawValue, on: req) 66 | .encode(for: req) 67 | } 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Reset/Command/GeneratePasswordResetTokenCommand.swift: -------------------------------------------------------------------------------- 1 | import Authentication 2 | import Command 3 | import Fluent 4 | import Sugar 5 | 6 | /// Generates password reset tokens for a user which can be used to reset their password. 7 | public struct GeneratePasswordResetTokenCommand: Command { 8 | 9 | /// See `Command` 10 | public let arguments: [CommandArgument] = [.argument(name: Keys.query)] 11 | 12 | /// See `CommandRunnable` 13 | public let options: [CommandOption] = [] 14 | 15 | /// See `CommandRunnable` 16 | public let help = ["Generates a password reset token for a user with a given id."] 17 | 18 | private let makeFilter: (String) -> FilterOperator 19 | private let databaseIdentifier: DatabaseIdentifier 20 | private let context: U.Context 21 | 22 | /// Creates a new password reset token command with a custom lookup strategy. 23 | /// 24 | /// Example to enable search by email: 25 | /// ``` 26 | /// GeneratePasswordResetTokenCommand(databaseIdentifier: .mysql) { query in 27 | /// try \User.email == $0 28 | /// } 29 | /// ``` 30 | /// 31 | /// - Parameters: 32 | /// - databaseIdentifier: identifier of database from where to load the user. 33 | /// - makeFilter: used to create the filter from the query. 34 | /// - context: The Reset context to use when generating the token. 35 | public init( 36 | databaseIdentifier: DatabaseIdentifier, 37 | makeFilter: @escaping (String) -> FilterOperator, 38 | context: U.Context = U.Context.requestResetPassword() 39 | ) { 40 | self.databaseIdentifier = databaseIdentifier 41 | self.makeFilter = makeFilter 42 | self.context = context 43 | } 44 | 45 | /// See `CommandRunnable` 46 | public func run(using context: CommandContext) throws -> Future { 47 | let container = context.container 48 | let query = try context.argument(Keys.query) 49 | let config: ResetConfig = try container.make() 50 | let expirationPeriod = U.expirationPeriod(for: self.context) 51 | let expirableSigner = ExpireableJWTSigner( 52 | expirationPeriod: expirationPeriod, 53 | signer: config.signer 54 | ) 55 | 56 | return container.withPooledConnection(to: databaseIdentifier) { connection in 57 | U.query(on: connection) 58 | .filter(self.makeFilter(query)) 59 | .first() 60 | .unwrap(or: ResetError.userNotFound) 61 | .flatMap(to: String.self) { user in 62 | try user.signToken(using: expirableSigner, on: container) 63 | } 64 | .map { 65 | context.console.print("Password Reset Token: \($0)") 66 | } 67 | } 68 | } 69 | } 70 | 71 | public extension GeneratePasswordResetTokenCommand where U.ID: LosslessStringConvertible { 72 | /// Creates a new password reset token command that looks up users by database identifier. 73 | /// 74 | /// - Parameters: 75 | /// - databaseIdentifier: identifier of database from where to load the user. 76 | /// - context: The Reset context to use when generating the token. 77 | init( 78 | databaseIdentifier: DatabaseIdentifier, 79 | context: U.Context = U.Context.requestResetPassword() 80 | ) { 81 | self.databaseIdentifier = databaseIdentifier 82 | self.makeFilter = { query -> FilterOperator in 83 | U.idKey == U.ID(query) 84 | } 85 | self.context = context 86 | } 87 | } 88 | 89 | private enum Keys { 90 | static let query = "query" 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Reset/PasswordResettable.swift: -------------------------------------------------------------------------------- 1 | import Authentication 2 | import Fluent 3 | import JWT 4 | import Submissions 5 | import Sugar 6 | import Vapor 7 | 8 | public protocol HasPasswordChangeCount { 9 | var passwordChangeCount: Int { get } 10 | } 11 | 12 | public protocol HasRequestResetPasswordContext { 13 | static func requestResetPassword() -> Self 14 | } 15 | 16 | public enum ResetPasswordContext: HasRequestResetPasswordContext { 17 | case userRequestedToResetPassword 18 | 19 | public static func requestResetPassword() -> ResetPasswordContext { 20 | return .userRequestedToResetPassword 21 | } 22 | } 23 | 24 | public protocol PasswordResettable: 25 | HasPassword, 26 | HasPasswordChangeCount, 27 | JWTAuthenticatable, 28 | Model 29 | where 30 | Self.JWTPayload: HasPasswordChangeCount 31 | { 32 | associatedtype Context: HasRequestResetPasswordContext 33 | associatedtype RequestReset: Creatable 34 | associatedtype ResetPassword: Creatable, HasReadablePassword 35 | 36 | static func find( 37 | by requestLink: RequestReset, 38 | on connection: DatabaseConnectable 39 | ) throws -> Future 40 | 41 | func sendPasswordReset( 42 | url: String, 43 | token: String, 44 | expirationPeriod: TimeInterval, 45 | context: Context, 46 | on req: Request 47 | ) throws -> Future 48 | 49 | /// By incrementing this value on each password change and including it in the JWT payload, 50 | /// this value ensures that a password reset token can only be used once. 51 | var passwordChangeCount: Int { get set } 52 | 53 | static func expirationPeriod(for context: Context) -> TimeInterval 54 | } 55 | 56 | public extension PasswordResettable { 57 | static func expirationPeriod(for context: Context) -> TimeInterval { 58 | return 1.hoursInSecs 59 | } 60 | } 61 | 62 | extension PasswordResettable where 63 | Self: PasswordAuthenticatable, 64 | Self.RequestReset: HasReadableUsername 65 | { 66 | public static func find( 67 | by payload: RequestReset, 68 | on connection: DatabaseConnectable 69 | ) -> Future { 70 | let username = payload[keyPath: RequestReset.readableUsernameKey] 71 | return query(on: connection).filter(Self.usernameKey == username).first() 72 | } 73 | } 74 | 75 | extension PasswordResettable where 76 | Self.JWTPayload: ModelPayloadType, 77 | Self == Self.JWTPayload.PayloadModel 78 | { 79 | public func makePayload( 80 | expirationTime: Date, 81 | on container: Container 82 | ) -> Future { 83 | return Future.map(on: container) { 84 | try Self.JWTPayload(expirationTime: expirationTime, model: self) 85 | } 86 | } 87 | } 88 | 89 | public protocol ModelPayloadType: ExpireableSubjectPayload, HasPasswordChangeCount { 90 | associatedtype PayloadModel: Model 91 | init(expirationTime: Date, model: PayloadModel) throws 92 | } 93 | 94 | public struct ModelPayload: 95 | ModelPayloadType 96 | where 97 | U: HasPasswordChangeCount, 98 | U.ID: LosslessStringConvertible 99 | { 100 | public typealias PayloadModel = U 101 | 102 | public let exp: ExpirationClaim 103 | public let pcc: PasswordChangeCountClaim 104 | public let sub: SubjectClaim 105 | 106 | public init( 107 | expirationTime: Date, 108 | model: U 109 | ) throws { 110 | self.exp = ExpirationClaim(value: expirationTime) 111 | self.pcc = PasswordChangeCountClaim(value: model.passwordChangeCount) 112 | self.sub = try SubjectClaim(value: model.requireID().description) 113 | } 114 | 115 | public var passwordChangeCount: Int { 116 | return pcc.value 117 | } 118 | 119 | public func verify(using signer: JWTSigner) throws { 120 | try exp.verifyNotExpired() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/Reset/Controllers/ResetController.swift: -------------------------------------------------------------------------------- 1 | import Authentication 2 | import JWT 3 | import Sugar 4 | import Vapor 5 | 6 | public protocol ResetControllerType { 7 | func renderResetPasswordRequestForm(_ req: Request) throws -> Future 8 | func resetPasswordRequest(_ req: Request) throws -> Future 9 | func renderResetPasswordForm(_ req: Request) throws -> Future 10 | func resetPassword(_ req: Request) throws -> Future 11 | } 12 | 13 | open class ResetController 14 | : ResetControllerType 15 | { 16 | public init() {} 17 | 18 | open func renderResetPasswordRequestForm(_ req: Request) throws -> Future { 19 | let config: ResetConfig = try req.make() 20 | return try config.responses.resetPasswordRequestForm(req) 21 | } 22 | 23 | open func resetPasswordRequest(_ req: Request) throws -> Future { 24 | let config: ResetConfig = try req.make() 25 | return U.RequestReset.create(on: req) 26 | .flatMap(to: U?.self) { try U.find(by: $0, on: req) } 27 | .flatTry { user in 28 | guard let user = user else { 29 | // ignore case where user could not be found to prevent malicious attackers from 30 | // finding out which accounts are available on the system 31 | return .done(on: req) 32 | } 33 | return try config.reset( 34 | user, 35 | context: U.Context.requestResetPassword(), 36 | on: req 37 | ) 38 | } 39 | .flatMap(to: Response.self) { _ in 40 | try config.responses.resetPasswordUserNotified(req) 41 | } 42 | } 43 | 44 | open func renderResetPasswordForm(_ req: Request) throws -> Future { 45 | let config: ResetConfig = try req.make() 46 | let payload = try config.extractVerifiedPayload(from: req.parameters.next()) 47 | 48 | return try U 49 | .authenticate(using: payload, on: req) 50 | .unwrap(or: ResetError.userNotFound) 51 | .flatMap(to: Response.self) { user in 52 | guard user.passwordChangeCount == payload.passwordChangeCount else { 53 | throw ResetError.tokenAlreadyUsed 54 | } 55 | return try config.responses.resetPasswordForm(req, user) 56 | } 57 | } 58 | 59 | open func resetPassword(_ req: Request) throws -> Future { 60 | let config: ResetConfig = try req.make() 61 | let payload = try config.extractVerifiedPayload(from: req.parameters.next()) 62 | 63 | return try U 64 | .authenticate(using: payload, on: req) 65 | .unwrap(or: ResetError.userNotFound) 66 | .try { user in 67 | guard user.passwordChangeCount == payload.passwordChangeCount else { 68 | throw ResetError.tokenAlreadyUsed 69 | } 70 | } 71 | .and(U.ResetPassword.create(on: req)) 72 | .flatMap(to: U.self) { user, resetPassword in 73 | var user = user 74 | let password = resetPassword[keyPath: U.ResetPassword.readablePasswordKey] 75 | user[keyPath: U.passwordKey] = try U.hashPassword(password) 76 | user.passwordChangeCount += 1 77 | return user.save(on: req) 78 | } 79 | .flatMap(to: Response.self) { user in 80 | try config.responses.resetPasswordSuccess(req, user) 81 | } 82 | } 83 | } 84 | 85 | public extension ResetConfig { 86 | func extractVerifiedPayload(from token: String) throws -> U.JWTPayload { 87 | let payload = try JWT(from: token, verifiedUsing: signer).payload 88 | 89 | try payload.verify(using: signer) 90 | 91 | return payload 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reset 🏳 2 | [![Swift Version](https://img.shields.io/badge/Swift-4.2-brightgreen.svg)](http://swift.org) 3 | [![Vapor Version](https://img.shields.io/badge/Vapor-3-30B6FC.svg)](http://vapor.codes) 4 | [![Circle CI](https://circleci.com/gh/nodes-vapor/reset/tree/master.svg?style=shield)](https://circleci.com/gh/nodes-vapor/reset) 5 | [![codebeat badge](https://codebeat.co/badges/06ad8513-8a2d-4e68-acd7-16c2953f9326)](https://codebeat.co/projects/github-com-nodes-vapor-reset-master) 6 | [![codecov](https://codecov.io/gh/nodes-vapor/reset/branch/master/graph/badge.svg)](https://codecov.io/gh/nodes-vapor/reset) 7 | [![Readme Score](http://readme-score-api.herokuapp.com/score.svg?url=https://github.com/nodes-vapor/reset)](http://clayallsopp.github.io/readme-score?url=https://github.com/nodes-vapor/reset) 8 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/reset/master/LICENSE) 9 | 10 | This package makes it easy to handle flows that involves resetting a password. It's up for the consumer to decide how to distribute the token that allows one to reset the password. 11 | 12 | ## 📦 Installation 13 | 14 | Add `Reset` to the package dependencies (in your `Package.swift` file): 15 | ```swift 16 | dependencies: [ 17 | ..., 18 | .package(url: "https://github.com/nodes-vapor/reset.git", from: "1.0.0") 19 | ] 20 | ``` 21 | 22 | as well as to your target (e.g. "App"): 23 | 24 | ```swift 25 | targets: [ 26 | ... 27 | .target( 28 | name: "App", 29 | dependencies: [... "Reset" ...] 30 | ), 31 | ... 32 | ] 33 | ``` 34 | 35 | Next, copy/paste the `Resources/Views/Reset` folder into your project in order to be able to use the provided Leaf files. These files can be changed as explained in the [Specifying the responses](#specifying-the-responses) section, however it's recommended to copy this folder to your project anyway. This makes it easier for you to keep track of updates and your project will work if you decide later on to not use your own customized leaf files. 36 | 37 | ## Getting started 🚀 38 | 39 | First make sure that you've imported Reset everywhere it's needed: 40 | 41 | ```swift 42 | import Reset 43 | ``` 44 | 45 | ### Adding the Provider 46 | 47 | Reset comes with a light-weight provider that we'll need to register in the `configure` function in our `configure.swift` file: 48 | 49 | ```swift 50 | try services.register(ResetProvider(config: ResetConfig( 51 | name: AppConfig.app.name, 52 | baseURL: AppConfig.app.url, 53 | signer: ExpireableJWTSigner( 54 | expirationPeriod: 3600, // 1 hour 55 | signer: .hs256( 56 | key: env(EnvironmentKey.Reset.signerKey, "secret-reset" 57 | ).convertToData()) 58 | ) 59 | )) 60 | ) 61 | ``` 62 | 63 | Please see [Making a `PasswordResettable` model](#making-a-passwordresettable-model) for more information on confirming a type to `PasswordResettable`. 64 | 65 | ### Adding the Reset routes 66 | 67 | Make sure to add the relevant Reset routes, e.g. in your configure.swift or routes.swift: 68 | 69 | ```swift 70 | services.register(Router.self) { container -> EngineRouter in 71 | let router = EngineRouter.default() 72 | try router.useResetRoutes(User.self, on: container) 73 | return router 74 | } 75 | ``` 76 | 77 | ### Adding the Leaf tag 78 | 79 | This package comes with a small Leaf tag that is used to pass Reset-related information such as project name and project url to Leaf. To add it to your project, please do the following: 80 | 81 | ```swift 82 | public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws { 83 | services.register { _ -> LeafTagConfig in 84 | var tags = LeafTagConfig.default() 85 | tags.useResetLeafTags() 86 | return tags 87 | } 88 | } 89 | ``` 90 | 91 | ## Making a `PasswordResettable` model 92 | 93 | There's a couple of things that needs to be in place for conforming your model to `PasswordResettable`. The following example is based on having a `User` model which you would like to add support for resetting a password. 94 | 95 | ### Request and reset structs 96 | 97 | The first thing to define is the data that is needed in order to request a reset password flow and the data for actually resetting the password. It could look like this: 98 | 99 | ```swift 100 | extension User: PasswordResettable { 101 | // ... 102 | 103 | public struct RequestReset: RequestCreatable, Decodable, HasReadableUsername { 104 | static let readableUsernameKey = \RequestReset.username 105 | public let username: String 106 | } 107 | 108 | public struct ResetPassword: RequestCreatable, Decodable, HasReadablePassword { 109 | static let readablePasswordKey = \ResetPassword.password 110 | public let password: String 111 | } 112 | 113 | // .. 114 | } 115 | ``` 116 | 117 | Basically the username (this could also be the email) is needed to request a reset flow and a new password is needed to submit the password change. 118 | 119 | > Note how that `RequestReset` conforms to `HasReadableUsername`. This enables Reset to implement the `find` method for looking up the user automatically. 120 | 121 | ### Sending the reset-password url 122 | 123 | Once the user has requested to reset their password, the `sendPasswordReset` function will be called. The implementation could send the url by email or just include the token in a text message. It's up to the implementer to decide how to distribute this. 124 | 125 | Here's an example using the [Mailgun](https://github.com/twof/VaporMailgunService) package to send out an email with the reset password url: 126 | 127 | ```swift 128 | extension User: PasswordResettable { 129 | // ... 130 | 131 | public func sendPasswordReset( 132 | url: String, 133 | token: String, 134 | expirationPeriod: TimeInterval, 135 | context: ResetPasswordContext, 136 | on req: Request 137 | ) throws -> Future { 138 | let mailgun = try req.make(Mailgun.self) 139 | let expire = Int(expirationPeriod / 60) // convert to minutes 140 | 141 | return try req 142 | .make(LeafRenderer.self) 143 | .render(ViewPath.Reset.resetPasswordEmail, ["url": url, "expire": expire]) 144 | .map(to: String.self) { view in 145 | String(bytes: view.data, encoding: .utf8) ?? "" 146 | } 147 | .map(to: Mailgun.Message.self) { html in 148 | Mailgun.Message( 149 | from: "donotreply@reset.com", 150 | to: self.email, 151 | subject: "Reset password", 152 | text: "Please turn on html to view this email.", 153 | html: html 154 | ) 155 | } 156 | .flatMap(to: Response.self) { message in 157 | try mailgun.send(message, on: req) 158 | } 159 | .transform(to: ()) 160 | } 161 | 162 | // .. 163 | } 164 | ``` 165 | 166 | ### Handling multiple reset flows 167 | 168 | There might be cases where you would want to have multiple signers for multiple different reset password flows. One example could be to handle the regular reset password flow as well as automatically resetting a password when a user gets created. By implementing the `signer` function, you're able to handle this: 169 | 170 | ```swift 171 | extension User: PasswordResettable { 172 | // ... 173 | 174 | public enum MyResetPasswordContext: HasRequestResetPasswordContext { 175 | case userRequestedToResetPassword 176 | case newUserWithoutPassword 177 | 178 | public static func requestResetPassword() -> MyResetPasswordContext { 179 | return .userRequestedToResetPassword 180 | } 181 | } 182 | 183 | public func signer( 184 | for context: MyResetPasswordContext, 185 | on container: Container 186 | ) throws -> ExpireableJWTSigner { 187 | let resetConfig: ResetConfig = try container.make() // The default signer 188 | let myConfig: MyConfig = try container.make() // Some project specific config that holds the extra signer 189 | 190 | switch context { 191 | case .userRequestedToResetPassword: return resetConfig.signer 192 | case .newUserWithoutPassword: return myConfig.newUserSetPasswordSigner 193 | } 194 | } 195 | 196 | // .. 197 | } 198 | ``` 199 | 200 | > Please note that you need to implement your own `Context` if you want to handle multiple signers. 201 | 202 | ## Specifying the responses 203 | 204 | All endpoints and responses that Reset uses can be overwritten. Reset provides responses for the following cases: 205 | 206 | - Form for requesting a reset password flow 207 | - Response for letting the user know that the reset password url has been sent 208 | - Form for resetting the password 209 | - Response for letting the user know that the password has been reset 210 | 211 | Here's a small example where the request to reset password should only be exposed through the API: 212 | 213 | ```swift 214 | 215 | let customResponse = ResetResponses( 216 | resetPasswordRequestForm: { req in 217 | return try HTTPResponse(status: .notFound).encode(for: req) 218 | }, 219 | resetPasswordUserNotified: { req in 220 | return try HTTPResponse(status: .noContent).encode(for: req) 221 | }, 222 | resetPasswordForm: { req, user in 223 | return try req 224 | .make(LeafRenderer.self) 225 | .render("MyPathForShowingResetForm") 226 | .encode(for: req) 227 | }, 228 | resetPasswordSuccess: { req, user in 229 | return try req 230 | .make(LeafRenderer.self) 231 | .render("MyPathForShowingResetPasswordSuccess") 232 | .encode(for: req) 233 | } 234 | ) 235 | 236 | ``` 237 | 238 | This instance can then be used when registering the provider as explained in [Adding the Provider](#adding-the-provider). 239 | 240 | Alternatively, instead of passing in `ResetResponses` in the `ResetConfig`, one could pass in their own implementation of `ResetControllerType` for full customizability. 241 | 242 | ## 🏆 Credits 243 | 244 | This package is developed and maintained by the Vapor team at [Nodes](https://www.nodesagency.com). 245 | 246 | 247 | ## 📄 License 248 | 249 | This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT) 250 | --------------------------------------------------------------------------------