├── .gitignore ├── LICENSE ├── README.md ├── images ├── 403-forbidden.png ├── common-login.png ├── creating-user.png ├── getting-data-as-admin.png ├── getting-protected-data.png ├── invalid-refresh-token.png ├── logging-as-admin.png ├── loging-in.png ├── refreshing-token.png ├── revoke-token.png ├── swagger.png └── unauthorized-for-admins.png ├── src └── JWTAPI │ ├── JWTAPI.sln │ └── JWTAPI │ ├── Controllers │ ├── LoginController.cs │ ├── ProtectedController.cs │ ├── Resources │ │ ├── RefreshTokenResource.cs │ │ ├── RevokeTokenResource.cs │ │ ├── TokenResource.cs │ │ ├── UserCredentialsResource.cs │ │ └── UserResource.cs │ └── UsersController.cs │ ├── Core │ ├── Models │ │ ├── ApplicationRole.cs │ │ ├── Role.cs │ │ ├── User.cs │ │ └── UserRole.cs │ ├── Repositories │ │ ├── IUnitOfWork.cs │ │ └── IUserRepository.cs │ ├── Security │ │ ├── Hashing │ │ │ └── IPasswordHasher.cs │ │ └── Tokens │ │ │ ├── AccessToken.cs │ │ │ ├── ITokenHandler.cs │ │ │ ├── JsonWebToken.cs │ │ │ ├── RefreshToken.cs │ │ │ └── RefreshTokenWithEmail.cs │ └── Services │ │ ├── Communication │ │ ├── BaseResponse.cs │ │ ├── CreateUserResponse.cs │ │ └── TokenResponse.cs │ │ ├── IAuthenticationService.cs │ │ └── IUserService.cs │ ├── Extensions │ ├── ApplicationServiceExtenstions.cs │ ├── IdentityServiceExtenstions.cs │ └── MiddlewareExtensions.cs │ ├── GlobalUsings.cs │ ├── JWTAPI.csproj │ ├── Mapping │ ├── ModelToResourceProfile.cs │ └── ResourceToModelProfile.cs │ ├── Persistence │ ├── AppDbContext.cs │ ├── DatabaseSeed.cs │ ├── UnitOfWork.cs │ └── UserRepository.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Security │ ├── Hashing │ │ └── PasswordHasher.cs │ └── Tokens │ │ ├── SigningConfigurations.cs │ │ ├── TokenHandler.cs │ │ └── TokenOptions.cs │ ├── Services │ ├── AuthenticationService.cs │ └── UserService.cs │ ├── appsettings.Development.json │ └── appsettings.json └── tests └── JWTAPI.Tests ├── JWTAPI.Tests.csproj ├── Security ├── Hashing │ └── PasswordHasherTests.cs └── Tokens │ └── TokenHandlerTests.cs ├── Services ├── AuthenticationServiceTests.cs └── UserServiceTests.cs └── Usings.cs /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | 84 | # Visual Studio profiler 85 | *.psess 86 | *.vsp 87 | *.vspx 88 | *.sap 89 | 90 | # TFS 2012 Local Workspace 91 | $tf/ 92 | 93 | # Guidance Automation Toolkit 94 | *.gpState 95 | 96 | # ReSharper is a .NET coding add-in 97 | _ReSharper*/ 98 | *.[Rr]e[Ss]harper 99 | *.DotSettings.user 100 | 101 | # JustCode is a .NET coding add-in 102 | .JustCode 103 | 104 | # TeamCity is a build add-in 105 | _TeamCity* 106 | 107 | # DotCover is a Code Coverage Tool 108 | *.dotCover 109 | 110 | # NCrunch 111 | _NCrunch_* 112 | .*crunch*.local.xml 113 | nCrunchTemp_* 114 | 115 | # MightyMoose 116 | *.mm.* 117 | AutoTest.Net/ 118 | 119 | # Web workbench (sass) 120 | .sass-cache/ 121 | 122 | # Installshield output folder 123 | [Ee]xpress/ 124 | 125 | # DocProject is a documentation generator add-in 126 | DocProject/buildhelp/ 127 | DocProject/Help/*.HxT 128 | DocProject/Help/*.HxC 129 | DocProject/Help/*.hhc 130 | DocProject/Help/*.hhk 131 | DocProject/Help/*.hhp 132 | DocProject/Help/Html2 133 | DocProject/Help/html 134 | 135 | # Click-Once directory 136 | publish/ 137 | 138 | # Publish Web Output 139 | *.[Pp]ublish.xml 140 | *.azurePubxml 141 | # TODO: Comment the next line if you want to checkin your web deploy settings 142 | # but database connection strings (with potential passwords) will be unencrypted 143 | *.pubxml 144 | *.publishproj 145 | 146 | # NuGet Packages 147 | *.nupkg 148 | # The packages folder can be ignored because of Package Restore 149 | **/packages/* 150 | # except build/, which is used as an MSBuild target. 151 | !**/packages/build/ 152 | # Uncomment if necessary however generally it will be regenerated when needed 153 | #!**/packages/repositories.config 154 | 155 | # Microsoft Azure Build Output 156 | csx/ 157 | *.build.csdef 158 | 159 | # Microsoft Azure Emulator 160 | ecf/ 161 | rcf/ 162 | 163 | # Microsoft Azure ApplicationInsights config file 164 | ApplicationInsights.config 165 | 166 | # Windows Store app package directory 167 | AppPackages/ 168 | BundleArtifacts/ 169 | 170 | # Visual Studio cache files 171 | # files ending in .cache can be ignored 172 | *.[Cc]ache 173 | # but keep track of directories ending in .cache 174 | !*.[Cc]ache/ 175 | 176 | # Others 177 | ClientBin/ 178 | ~$* 179 | *~ 180 | *.dbmdl 181 | *.dbproj.schemaview 182 | *.pfx 183 | *.publishsettings 184 | node_modules/ 185 | orleans.codegen.cs 186 | 187 | # RIA/Silverlight projects 188 | Generated_Code/ 189 | 190 | # Backup & report files from converting an old project file 191 | # to a newer Visual Studio version. Backup files are not needed, 192 | # because we have git ;-) 193 | _UpgradeReport_Files/ 194 | Backup*/ 195 | UpgradeLog*.XML 196 | UpgradeLog*.htm 197 | 198 | # SQL Server files 199 | *.mdf 200 | *.ldf 201 | 202 | # Business Intelligence projects 203 | *.rdl.data 204 | *.bim.layout 205 | *.bim_*.settings 206 | 207 | # Microsoft Fakes 208 | FakesAssemblies/ 209 | 210 | # GhostDoc plugin setting file 211 | *.GhostDoc.xml 212 | 213 | # Node.js Tools for Visual Studio 214 | .ntvs_analysis.dat 215 | 216 | # Visual Studio 6 build log 217 | *.plg 218 | 219 | # Visual Studio 6 workspace options file 220 | *.opt 221 | 222 | # Visual Studio LightSwitch build output 223 | **/*.HTMLClient/GeneratedArtifacts 224 | **/*.DesktopClient/GeneratedArtifacts 225 | **/*.DesktopClient/ModelManifest.xml 226 | **/*.Server/GeneratedArtifacts 227 | **/*.Server/ModelManifest.xml 228 | _Pvt_Extensions 229 | 230 | # Paket dependency manager 231 | .paket/paket.exe 232 | 233 | # FAKE - F# Make 234 | .fake/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Evandro Gayer Gomes 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JWT API 2 | 3 | This example API shows how to implement JSON Web Token authentication and authorization with ASP.NET Core 7, built from scratch. 4 | 5 | ### Features 6 | - User registration; 7 | - Password hashing; 8 | - Role-based authorization; 9 | - Login via access token creation; 10 | - Refresh tokens, to create new access tokens when access tokens expire; 11 | - Revoking refresh tokens. 12 | 13 | ### Frameworks and Libraries 14 | 15 | The API uses the following libraries and frameworks to deliver the functionalities described above: 16 | - [Entity Framework Core](https://github.com/aspnet/EntityFrameworkCore) (for data access) 17 | - [AutoMapper](https://github.com/AutoMapper/AutoMapper) (for mapping between domain entities and resource classes) 18 | 19 | ### How to test 20 | 21 | I added [Swagger](https://swagger.io/) to the API, so we can use it to visualize and test all API routes. You can run the application and navigate to `/swagger` to see the API documentation: 22 | 23 | ![Swagger](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/swagger.png) 24 | 25 | You can also test the API using a tool such as [Postman](https://www.getpostman.com/). I describe how to use Postman to test the API below. 26 | 27 | First of all, clone this repository and open it in a terminal. Then restore all the dependencies and run the project. Since it is configured to use [Entity Framework InMemory](https://docs.microsoft.com/en-us/ef/core/providers/in-memory/) provider, the project will run without any problems. 28 | 29 | ```sh 30 | $ git clone https://github.com/evgomes/jwt-api.git 31 | $ cd jwt-api/src 32 | $ dotnet restore 33 | $ dotnet run 34 | ``` 35 | 36 | #### Creating users 37 | 38 | To create a user, make a `POST` request to `http://localhost:5000/api/users` specifying a valid e-mail and password. The result will be a new user with common users permission. 39 | 40 | ``` 41 | { 42 | "email": "mytest@mytest.com", 43 | "password": "123456" 44 | } 45 | ``` 46 | 47 | ![Creating an user](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/creating-user.png) 48 | 49 | There are already two pre-defined users configured to test the application, one with common users permission and another with admin permissions. 50 | 51 | ``` 52 | { 53 | "email": "common@common.com", 54 | "password": "12345678" 55 | } 56 | ``` 57 | 58 | ``` 59 | { 60 | "email": "admin@admin.com", 61 | "password": "12345678" 62 | } 63 | ``` 64 | 65 | #### Requesting access tokens 66 | 67 | To request the access tokens, make a `POST` request to `http://localhost:5000/api/login` sending a JSON object with user credentials. The response will be a JSON object with: 68 | 69 | - An access token which can be used to access protected API endpoints; 70 | - A request token, necessary to get a new access token when an access token expires; 71 | - A long value that represents the expiration date of the token. 72 | 73 | Access tokens expire after 30 seconds, and refresh tokens after 60 seconds (you can change this in the `appsetings.json`). 74 | 75 | ![Requesting a token](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/loging-in.png) 76 | 77 | #### Accessing protected data 78 | 79 | There are two API endpoints that you can test: 80 | 81 | - `http://localhost:5000/api/protectedforcommonusers`: users of all roles can access this endpoint if a valid access token is specified; 82 | - `http://localhost:5000/api/protectedforadministrators`: only admin users can access this endpoint. 83 | 84 | With a valid access token in hands, make a `GET` request to one of the endpoints mentioned above with the following header added to your request: 85 | 86 | `Authorization: Bearer your_valid_access_token_here` 87 | 88 | If you get a token as a common user (a user that has the `Common` role) and make a request to the endpoint for all users, you will get a response as follows: 89 | 90 | ![Common user](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/getting-protected-data.png) 91 | 92 | But if you try to pass this token to the endpoint that requires admin permission, you will get a `403 - forbidden` response: 93 | 94 | ![403 Forbidden](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/403-forbidden.png) 95 | 96 | If you sign in as an admin and make a `GET` request to the admin endpoint, you will receive the following content as response: 97 | 98 | ![Admin restricted data](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/getting-data-as-admin.png) 99 | 100 | If you pass an invalid token to any of the endpoints (a expired one or a token that was changed by hand, for example), you will get a `401 unauthorized` response. 101 | 102 | ![401 Unauthorized](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/unauthorized-for-admins.png) 103 | 104 | #### Refreshing tokens 105 | 106 | Imagine you have a single page application or a mobile app and you do not want the users to have to log in again every time an access token expires. To deal with this, you can get a new token with a valid refresh token. This way, you can keep users logged in without explicitly asking them to sign in again. 107 | 108 | To refresh a token, make a `POST` request to `http://localhost:5000/api/token/refresh` passing a valid refresh token and the user's e-mail in the body of the request. 109 | 110 | ``` 111 | { 112 | "token": "your_valid_refresh_token", 113 | "userEmail": "user@email.com" 114 | } 115 | ``` 116 | 117 | You will receive a new token if the specified refresh token and e-mail are valid: 118 | 119 | ![Refreshing token](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/refreshing-token.png) 120 | 121 | If the request token is invalid, you will receive a 400 response: 122 | 123 | ![Invalid refresh token](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/invalid-refresh-token.png) 124 | 125 | #### Revoking refresh tokens 126 | 127 | Now imagine that you want the user to sign out, or you want to revoke a refresh token for any reason. You can revoke a refresh token making a POST request to `http://localhost:5000/api/token/revoke`, passing a valid refresh token into the body of the request. 128 | 129 | ``` 130 | { 131 | "token": "valid_refresh_token" 132 | } 133 | ``` 134 | 135 | You will get a `204 No Content` response after calling this endpoint. 136 | 137 | ![Revoking token](https://raw.githubusercontent.com/evgomes/jwt-api/master/images/revoke-token.png) 138 | 139 | ### Considerations 140 | 141 | This example was created with the intent of helping people who have doubts on how to implement authentication and authorization in APIs to consume these features in different client applications. JSON Web Tokens are easy to implement and secure. 142 | 143 | If you have doubts about the implementation details or if you find a bug, please, open an issue. If you have ideas on how to improve the API or if you want to add a new functionality or fix a bug, please, send a pull request. -------------------------------------------------------------------------------- /images/403-forbidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/403-forbidden.png -------------------------------------------------------------------------------- /images/common-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/common-login.png -------------------------------------------------------------------------------- /images/creating-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/creating-user.png -------------------------------------------------------------------------------- /images/getting-data-as-admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/getting-data-as-admin.png -------------------------------------------------------------------------------- /images/getting-protected-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/getting-protected-data.png -------------------------------------------------------------------------------- /images/invalid-refresh-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/invalid-refresh-token.png -------------------------------------------------------------------------------- /images/logging-as-admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/logging-as-admin.png -------------------------------------------------------------------------------- /images/loging-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/loging-in.png -------------------------------------------------------------------------------- /images/refreshing-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/refreshing-token.png -------------------------------------------------------------------------------- /images/revoke-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/revoke-token.png -------------------------------------------------------------------------------- /images/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/swagger.png -------------------------------------------------------------------------------- /images/unauthorized-for-admins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/jwt-api/27029b4349562fa30f08ac73f557e893e51761f5/images/unauthorized-for-admins.png -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.28307.572 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JWTAPI", "JWTAPI\JWTAPI.csproj", "{9751E06C-4EB9-446E-9EA8-B3B5EC7DD7D8}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JWTAPI.Tests", "..\..\tests\JWTAPI.Tests\JWTAPI.Tests.csproj", "{A8EF1944-2D7F-445A-8ACA-2FEAAD7D2664}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {9751E06C-4EB9-446E-9EA8-B3B5EC7DD7D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {9751E06C-4EB9-446E-9EA8-B3B5EC7DD7D8}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {9751E06C-4EB9-446E-9EA8-B3B5EC7DD7D8}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {9751E06C-4EB9-446E-9EA8-B3B5EC7DD7D8}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {A8EF1944-2D7F-445A-8ACA-2FEAAD7D2664}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {A8EF1944-2D7F-445A-8ACA-2FEAAD7D2664}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {A8EF1944-2D7F-445A-8ACA-2FEAAD7D2664}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {A8EF1944-2D7F-445A-8ACA-2FEAAD7D2664}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {75D538DA-D647-4594-AEA5-49BC3B6899DE} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/LoginController.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers; 2 | 3 | [ApiController] 4 | [Route("api/")] 5 | public class AuthController : ControllerBase 6 | { 7 | private readonly IMapper _mapper; 8 | private readonly IAuthenticationService _authenticationService; 9 | 10 | public AuthController(IMapper mapper, IAuthenticationService authenticationService) 11 | { 12 | _authenticationService = authenticationService; 13 | _mapper = mapper; 14 | } 15 | 16 | [HttpPost("login")] 17 | public async Task LoginAsync([FromBody] UserCredentialsResource userCredentials) 18 | { 19 | var response = await _authenticationService.CreateAccessTokenAsync(userCredentials.Email!, userCredentials.Password!); 20 | if (!response.Success) 21 | { 22 | return BadRequest(response.Message); 23 | } 24 | 25 | return Ok(_mapper.Map(response.Token)); 26 | } 27 | 28 | [HttpPost("token/refresh")] 29 | public async Task RefreshTokenAsync([FromBody] RefreshTokenResource refreshTokenResource) 30 | { 31 | var response = await _authenticationService.RefreshTokenAsync(refreshTokenResource.Token!, refreshTokenResource.UserEmail!); 32 | if (!response.Success) 33 | { 34 | return BadRequest(response.Message); 35 | } 36 | 37 | return Ok(_mapper.Map(response.Token)); 38 | } 39 | 40 | [HttpPost("token/revoke")] 41 | public IActionResult RevokeToken([FromBody] RevokeTokenResource resource) 42 | { 43 | _authenticationService.RevokeRefreshToken(resource.Token!, resource.Email!); 44 | return NoContent(); 45 | } 46 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/ProtectedController.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers; 2 | 3 | [ApiController] 4 | [Route("api/protected")] 5 | public class ProtectedController : ControllerBase 6 | { 7 | [HttpGet] 8 | [Authorize] 9 | [Route("for-commonusers")] 10 | public IActionResult GetProtectedData() 11 | { 12 | return Ok("Hello world from protected controller."); 13 | } 14 | 15 | [HttpGet] 16 | [Authorize(Roles = "Administrator")] 17 | [Route("for-administrators")] 18 | public IActionResult GetProtectedDataForAdmin() 19 | { 20 | return Ok("Hello admin!"); 21 | } 22 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/Resources/RefreshTokenResource.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers.Resources; 2 | 3 | public record RefreshTokenResource 4 | { 5 | [Required] 6 | public string? Token { get; init; } 7 | 8 | [Required] 9 | [EmailAddress] 10 | [StringLength(255)] 11 | public string? UserEmail { get; init; } 12 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/Resources/RevokeTokenResource.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers.Resources; 2 | 3 | public class RevokeTokenResource 4 | { 5 | [Required] 6 | public string? Token { get; init; } 7 | 8 | [Required] 9 | public string? Email { get; init; } 10 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/Resources/TokenResource.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers.Resources; 2 | 3 | public class AccessTokenResource 4 | { 5 | public string AccessToken { get; init; } = null!; 6 | public string RefreshToken { get; init; } = null!; 7 | public long Expiration { get; set; } 8 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/Resources/UserCredentialsResource.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers.Resources; 2 | 3 | public class UserCredentialsResource 4 | { 5 | [Required] 6 | [EmailAddress] 7 | [StringLength(255)] 8 | public string? Email { get; init; } 9 | 10 | [Required] 11 | [StringLength(32)] 12 | public string? Password { get; init; } 13 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/Resources/UserResource.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers.Resources; 2 | 3 | public record UserResource 4 | { 5 | public int Id { get; set; } 6 | public string Email { get; set; } = null!; 7 | public IEnumerable Roles { get; set; } = new List(); 8 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Controllers/UsersController.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Controllers; 2 | 3 | [ApiController] 4 | [Route("/api/users")] 5 | public class UsersController : ControllerBase 6 | { 7 | private readonly IMapper _mapper; 8 | private readonly IUserService _userService; 9 | 10 | public UsersController(IUserService userService, IMapper mapper) 11 | { 12 | _userService = userService; 13 | _mapper = mapper; 14 | } 15 | 16 | [HttpPost] 17 | public async Task CreateUserAsync([FromBody] UserCredentialsResource userCredentials) 18 | { 19 | var user = _mapper.Map(userCredentials); 20 | 21 | var response = await _userService.CreateUserAsync(user, ApplicationRole.Common); 22 | if (!response.Success) 23 | { 24 | return BadRequest(response.Message); 25 | } 26 | 27 | return Ok(_mapper.Map(response.User)); 28 | } 29 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Models/ApplicationRole.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Models; 2 | public enum ApplicationRole 3 | { 4 | Common = 1, 5 | Administrator = 2 6 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Models/Role.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Models; 2 | public class Role 3 | { 4 | public int Id { get; set; } 5 | 6 | [Required] 7 | [StringLength(50)] 8 | public string Name { get; set; } = null!; 9 | 10 | public virtual ICollection UsersRole { get; set; } = new Collection(); 11 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Models/User.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Models; 2 | public class User 3 | { 4 | public int Id { get; set; } 5 | 6 | [Required] 7 | [EmailAddress] 8 | [StringLength(255)] 9 | public string Email { get; set; } = null!; 10 | 11 | [Required] 12 | public string Password { get; set; } = null!; 13 | 14 | public ICollection UserRoles { get; set; } = new Collection(); 15 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Models/UserRole.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Models; 2 | 3 | [Table("UserRoles")] 4 | public class UserRole 5 | { 6 | public int UserId { get; set; } 7 | public User? User { get; set; } 8 | 9 | public int RoleId { get; set; } 10 | public Role? Role { get; set; } 11 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Repositories/IUnitOfWork.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Repositories; 2 | public interface IUnitOfWork 3 | { 4 | Task CompleteAsync(); 5 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Repositories/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Repositories; 2 | public interface IUserRepository 3 | { 4 | Task AddAsync(User user, ApplicationRole[] userRoles); 5 | Task FindByEmailAsync(string email); 6 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Security/Hashing/IPasswordHasher.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Security.Hashing; 2 | public interface IPasswordHasher 3 | { 4 | string HashPassword(string password); 5 | bool ValidatePassword(string providedPassword, string passwordHash); 6 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Security/Tokens/AccessToken.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Security.Tokens; 2 | public class AccessToken : JsonWebToken 3 | { 4 | public RefreshToken RefreshToken { get; private set; } 5 | 6 | public AccessToken(string token, long expiration, RefreshToken refreshToken) : base(token, expiration) 7 | { 8 | RefreshToken = refreshToken 9 | ?? throw new ArgumentNullException("Specify a valid refresh token."); 10 | } 11 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Security/Tokens/ITokenHandler.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Security.Tokens; 2 | 3 | public interface ITokenHandler 4 | { 5 | AccessToken CreateAccessToken(User user); 6 | RefreshToken? TakeRefreshToken(string token, string userEmail); 7 | void RevokeRefreshToken(string token, string userEmail); 8 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Security/Tokens/JsonWebToken.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Security.Tokens; 2 | 3 | public abstract class JsonWebToken 4 | { 5 | public string Token { get; protected set; } 6 | public long Expiration { get; protected set; } 7 | 8 | public JsonWebToken(string token, long expiration) 9 | { 10 | if(string.IsNullOrWhiteSpace(token)) 11 | throw new ArgumentException("Invalid token."); 12 | 13 | if(expiration <= 0) 14 | throw new ArgumentException("Invalid expiration."); 15 | 16 | Token = token; 17 | Expiration = expiration; 18 | } 19 | 20 | public bool IsExpired() => DateTime.UtcNow.Ticks > Expiration; 21 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Security/Tokens/RefreshToken.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Security.Tokens; 2 | public class RefreshToken : JsonWebToken 3 | { 4 | public RefreshToken(string token, long expiration) : base(token, expiration) 5 | { } 6 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Security/Tokens/RefreshTokenWithEmail.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Security.Tokens; 2 | public class RefreshTokenWithEmail 3 | { 4 | public string Email { get; set; } = null!; 5 | public RefreshToken RefreshToken { get; set; } = null!; 6 | } 7 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Services/Communication/BaseResponse.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Services.Communication; 2 | public abstract class BaseResponse 3 | { 4 | public bool Success { get; protected set; } 5 | public string? Message { get; protected set; } 6 | 7 | public BaseResponse(bool success, string? message) 8 | { 9 | Success = success; 10 | Message = message; 11 | } 12 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Services/Communication/CreateUserResponse.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Services.Communication; 2 | public class CreateUserResponse : BaseResponse 3 | { 4 | public User? User { get; private set; } 5 | 6 | public CreateUserResponse(bool success, string? message, User? user) : base(success, message) 7 | { 8 | User = user; 9 | } 10 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Services/Communication/TokenResponse.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Services.Communication; 2 | public class TokenResponse : BaseResponse 3 | { 4 | public AccessToken? Token { get; set; } 5 | 6 | public TokenResponse(bool success, string? message, AccessToken? token) : base(success, message) 7 | { 8 | Token = token; 9 | } 10 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Services/IAuthenticationService.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Services; 2 | 3 | public interface IAuthenticationService 4 | { 5 | Task CreateAccessTokenAsync(string email, string password); 6 | Task RefreshTokenAsync(string refreshToken, string userEmail); 7 | void RevokeRefreshToken(string refreshToken, string userEmail); 8 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Core/Services/IUserService.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Core.Services; 2 | 3 | public interface IUserService 4 | { 5 | Task CreateUserAsync(User user, params ApplicationRole[] userRoles); 6 | Task FindByEmailAsync(string email); 7 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Extensions/ApplicationServiceExtenstions.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Extensions; 2 | public static class ApplicationServiceExtenstions 3 | { 4 | public static IServiceCollection AddApplicationServices( 5 | this IServiceCollection services) 6 | { 7 | services.AddControllers(); 8 | 9 | services.AddDbContext(options => 10 | { 11 | options.UseInMemoryDatabase("jwtapi"); 12 | }); 13 | 14 | services.AddScoped(); 15 | 16 | services.AddScoped(); 17 | 18 | services.AddSingleton(); 19 | 20 | services.AddSingleton(); 21 | 22 | services.AddScoped(); 23 | 24 | services.AddScoped(); 25 | 26 | services.AddAutoMapper(Assembly.GetExecutingAssembly()); 27 | 28 | return services; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Extensions/IdentityServiceExtenstions.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Extensions; 2 | public static class IdentityServiceExtenstions 3 | { 4 | public static IServiceCollection AddIdentityServices( 5 | this IServiceCollection services, 6 | IConfiguration configuration) 7 | { 8 | services.Configure(configuration.GetSection("TokenOptions")); 9 | 10 | var tokenOptions = configuration.GetSection("TokenOptions").Get() ?? throw new ArgumentNullException(nameof(TokenOptions)); 11 | 12 | var signingConfigurations = new SigningConfigurations(tokenOptions.Secret); 13 | 14 | services.AddSingleton(signingConfigurations); 15 | 16 | services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 17 | .AddJwtBearer(jwtBearerOptions => 18 | { 19 | jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters() 20 | { 21 | ValidateAudience = true, 22 | ValidateLifetime = true, 23 | ValidateIssuerSigningKey = true, 24 | ValidIssuer = tokenOptions.Issuer, 25 | ValidAudience = tokenOptions.Audience, 26 | IssuerSigningKey = signingConfigurations.SecurityKey, 27 | ClockSkew = TimeSpan.Zero 28 | }; 29 | }); 30 | 31 | return services; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Extensions/MiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Extensions; 2 | 3 | public static class MiddlewareExtensions 4 | { 5 | public static IServiceCollection AddCustomSwagger(this IServiceCollection services) 6 | { 7 | services.AddSwaggerGen(cfg => 8 | { 9 | cfg.SwaggerDoc("v1", new OpenApiInfo 10 | { 11 | Title = "JWT API", 12 | Version = "v4", 13 | Description = "Example API that shows how to implement JSON Web Token authentication and authorization with ASP.NET Core 7, built from scratch.", 14 | Contact = new OpenApiContact 15 | { 16 | Name = "Evandro Gayer Gomes", 17 | Url = new Uri("https://www.linkedin.com/in/evandro-gayer-gomes/?locale=en_US") 18 | }, 19 | License = new OpenApiLicense 20 | { 21 | Name = "MIT", 22 | }, 23 | }); 24 | 25 | cfg.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme 26 | { 27 | In = ParameterLocation.Header, 28 | Description = "JSON Web Token to access resources. Example: Bearer {token}", 29 | Name = "Authorization", 30 | Type = SecuritySchemeType.ApiKey 31 | }); 32 | 33 | cfg.AddSecurityRequirement(new OpenApiSecurityRequirement 34 | { 35 | { 36 | new OpenApiSecurityScheme 37 | { 38 | Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } 39 | }, 40 | new [] { string.Empty } 41 | } 42 | }); 43 | }); 44 | 45 | return services; 46 | } 47 | 48 | public static IApplicationBuilder UseCustomSwagger(this IApplicationBuilder app) 49 | { 50 | app.UseSwagger().UseSwaggerUI(options => 51 | { 52 | options.SwaggerEndpoint("/swagger/v1/swagger.json", "JWT API"); 53 | options.DocumentTitle = "JWT API"; 54 | }); 55 | 56 | return app; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using AutoMapper; 2 | global using JWTAPI.Controllers.Resources; 3 | global using JWTAPI.Core.Models; 4 | global using JWTAPI.Core.Repositories; 5 | global using JWTAPI.Core.Security.Hashing; 6 | global using JWTAPI.Core.Security.Tokens; 7 | global using JWTAPI.Core.Services; 8 | global using JWTAPI.Core.Services.Communication; 9 | global using JWTAPI.Extensions; 10 | global using JWTAPI.Persistence; 11 | global using JWTAPI.Security.Hashing; 12 | global using JWTAPI.Security.Tokens; 13 | global using JWTAPI.Services; 14 | global using Microsoft.AspNetCore.Authentication.JwtBearer; 15 | global using Microsoft.AspNetCore.Authorization; 16 | global using Microsoft.AspNetCore.Mvc; 17 | global using Microsoft.EntityFrameworkCore; 18 | global using Microsoft.Extensions.Options; 19 | global using Microsoft.IdentityModel.Tokens; 20 | global using Microsoft.OpenApi.Models; 21 | global using System.Collections.ObjectModel; 22 | global using System.ComponentModel.DataAnnotations; 23 | global using System.ComponentModel.DataAnnotations.Schema; 24 | global using System.IdentityModel.Tokens.Jwt; 25 | global using System.Reflection; 26 | global using System.Runtime.CompilerServices; 27 | global using System.Security.Claims; 28 | global using System.Security.Cryptography; 29 | global using System.Text; 30 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/JWTAPI.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | True 8 | bin\Debug\net7.0\JWTAPI.xml 9 | 1591 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Mapping/ModelToResourceProfile.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Mapping; 2 | public class ModelToResourceProfile : Profile 3 | { 4 | public ModelToResourceProfile() 5 | { 6 | CreateMap() 7 | .ForMember(u => u.Roles, opt => opt.MapFrom(u => u.UserRoles.Select(ur => ur.Role!.Name))); 8 | 9 | CreateMap() 10 | .ForMember(a => a.AccessToken, opt => opt.MapFrom(a => a.Token)) 11 | .ForMember(a => a.RefreshToken, opt => opt.MapFrom(a => a.RefreshToken.Token)) 12 | .ForMember(a => a.Expiration, opt => opt.MapFrom(a => a.Expiration)); 13 | } 14 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Mapping/ResourceToModelProfile.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Mapping; 2 | public class ResourceToModelProfile : Profile 3 | { 4 | public ResourceToModelProfile() 5 | { 6 | CreateMap(); 7 | } 8 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Persistence/AppDbContext.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Persistence; 2 | public class AppDbContext : DbContext 3 | { 4 | public DbSet Users { get; set; } 5 | public DbSet Roles { get; set; } 6 | 7 | public AppDbContext(DbContextOptions options) : base(options) 8 | { } 9 | 10 | protected override void OnModelCreating(ModelBuilder builder) 11 | { 12 | base.OnModelCreating(builder); 13 | 14 | builder.Entity().HasKey(ur => new { ur.UserId, ur.RoleId }); 15 | } 16 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Persistence/DatabaseSeed.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Persistence; 2 | 3 | /// 4 | /// EF Core already supports database seeding throught overriding "OnModelCreating", but I decided to create a separate seed class to avoid 5 | /// injecting IPasswordHasher into AppDbContext. 6 | /// To understand how to use database seeding into DbContext classes, check this link: https://docs.microsoft.com/en-us/ef/core/modeling/data-seeding 7 | /// 8 | public class DatabaseSeed 9 | { 10 | public static async Task SeedAsync(AppDbContext context, IPasswordHasher passwordHasher) 11 | { 12 | context.Database.EnsureCreated(); 13 | 14 | if (await context.Roles.AnyAsync()) return; 15 | 16 | var roles = new List 17 | { 18 | new Role { Name = ApplicationRole.Common.ToString() }, 19 | new Role { Name = ApplicationRole.Administrator.ToString() } 20 | }; 21 | 22 | context.Roles.AddRange(roles); 23 | await context.SaveChangesAsync(); 24 | 25 | var users = new List 26 | { 27 | new User { Email = "admin@admin.com", Password = passwordHasher.HashPassword("12345678") }, 28 | new User { Email = "common@common.com", Password = passwordHasher.HashPassword("12345678") }, 29 | }; 30 | 31 | users[0].UserRoles.Add(new UserRole 32 | { 33 | RoleId = context.Roles.Single(r => r.Name == ApplicationRole.Administrator.ToString()).Id, 34 | Role = new Role 35 | { 36 | Name = ApplicationRole.Administrator.ToString() 37 | } 38 | }); 39 | 40 | users[1].UserRoles.Add(new UserRole 41 | { 42 | RoleId = context.Roles.Single(r => r.Name == ApplicationRole.Common.ToString()).Id, 43 | Role = new Role 44 | { 45 | Name = ApplicationRole.Common.ToString() 46 | } 47 | }); 48 | 49 | context.Users.AddRange(users); 50 | await context.SaveChangesAsync(); 51 | } 52 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Persistence/UnitOfWork.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Persistence; 2 | public class UnitOfWork : IUnitOfWork 3 | { 4 | private readonly AppDbContext _context; 5 | 6 | public UnitOfWork(AppDbContext context) 7 | { 8 | _context = context; 9 | } 10 | 11 | public async Task CompleteAsync() 12 | { 13 | await _context.SaveChangesAsync(); 14 | } 15 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Persistence/UserRepository.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Persistence; 2 | public class UserRepository : IUserRepository 3 | { 4 | private readonly AppDbContext _context; 5 | 6 | public UserRepository(AppDbContext context) 7 | { 8 | _context = context; 9 | } 10 | 11 | public async Task AddAsync(User user, ApplicationRole[] userRoles) 12 | { 13 | var roleNames = userRoles.Select(r => r.ToString()).ToList(); 14 | var roles = await _context.Roles.Where(r => roleNames.Contains(r.Name)).ToListAsync(); 15 | 16 | foreach (var role in roles) 17 | { 18 | user.UserRoles.Add(new UserRole { RoleId = role.Id }); 19 | } 20 | 21 | _context.Users.Add(user); 22 | } 23 | 24 | public async Task FindByEmailAsync(string email) 25 | { 26 | return await _context.Users 27 | .Include(_ => _.UserRoles) 28 | .ThenInclude(_ => _.Role) 29 | .FirstOrDefaultAsync(_ => _.Email.Equals(email)); 30 | } 31 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | builder.Services.AddCustomSwagger(); 4 | 5 | builder.Services.AddApplicationServices(); 6 | 7 | builder.Services.AddIdentityServices(builder.Configuration); 8 | 9 | var app = builder.Build(); 10 | 11 | app.UseDeveloperExceptionPage(); 12 | 13 | app.UseRouting(); 14 | 15 | app.UseCustomSwagger(); 16 | 17 | app.UseAuthentication(); 18 | 19 | app.UseAuthorization(); 20 | 21 | #pragma warning disable ASP0014 // Suggest using top level route registrations 22 | app.UseEndpoints(endpoints => 23 | { 24 | endpoints.MapControllers(); 25 | }); 26 | #pragma warning restore ASP0014 // Suggest using top level route registrations 27 | 28 | using var scope = app.Services.CreateScope(); 29 | try 30 | { 31 | var dbContext = scope.ServiceProvider.GetRequiredService(); 32 | var passwordHasher = scope.ServiceProvider.GetRequiredService(); 33 | await DatabaseSeed.SeedAsync(dbContext, passwordHasher); 34 | } 35 | catch (Exception ex) 36 | { 37 | var logger = scope.ServiceProvider.GetRequiredService>(); 38 | logger.LogError(ex, "An error occured while applying migrations"); 39 | } 40 | 41 | await app.RunAsync(); -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5000", 7 | "sslPort": 44341 8 | } 9 | }, 10 | "$schema": "http://json.schemastore.org/launchsettings.json", 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "swagger", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "JWTAPI": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "swagger", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Security/Hashing/PasswordHasher.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Security.Hashing; 2 | 3 | /// 4 | /// This password hasher is the same used by ASP.NET Identity. 5 | /// Explanation: https://stackoverflow.com/questions/20621950/asp-net-identity-default-password-hasher-how-does-it-work-and-is-it-secure 6 | /// Full implementation: https://gist.github.com/malkafly/e873228cb9515010bdbe 7 | /// 8 | public class PasswordHasher : IPasswordHasher 9 | { 10 | public string HashPassword(string password) 11 | { 12 | byte[] salt; 13 | byte[] passwordHashKey; 14 | if (string.IsNullOrEmpty(password)) 15 | { 16 | throw new ArgumentNullException(nameof(password)); 17 | } 18 | using (Rfc2898DeriveBytes Key = new Rfc2898DeriveBytes(password, 0x10, 0x3e8, HashAlgorithmName.SHA512)) 19 | { 20 | salt = Key.Salt; 21 | passwordHashKey = Key.GetBytes(0x20); 22 | } 23 | byte[] storingPasswordArray = new byte[0x31]; 24 | Buffer.BlockCopy(salt, 0, storingPasswordArray, 1, 0x10); 25 | Buffer.BlockCopy(passwordHashKey, 0, storingPasswordArray, 0x11, 0x20); 26 | return Convert.ToBase64String(storingPasswordArray); 27 | } 28 | 29 | public bool ValidatePassword(string userInputData, string storedPasswordHash) 30 | { 31 | if (string.IsNullOrEmpty(storedPasswordHash)) 32 | return false; 33 | if (userInputData is null) 34 | throw new ArgumentNullException(userInputData, "User Password should not be null !"); 35 | 36 | byte[] decodedStoredPasswordHash = Convert.FromBase64String(storedPasswordHash); 37 | 38 | if (IsInvalid(decodedStoredPasswordHash)) 39 | return false; 40 | 41 | byte[] salt = ExtractPortion(decodedStoredPasswordHash, 1, 0x10); 42 | byte[] expectedPassword = ExtractPortion(decodedStoredPasswordHash,0x11, 0x20); 43 | 44 | byte[] GeneratedKey = DeriveKeyFromPassword(userInputData,salt); 45 | 46 | return ByteArraysEqual(expectedPassword, GeneratedKey); 47 | 48 | } 49 | 50 | private bool IsInvalid(byte[] hashed) 51 | { 52 | return hashed.Length != 0x31 || hashed[0] != 0; 53 | } 54 | 55 | private byte[] DeriveKeyFromPassword(string userInputData, byte[] salt) 56 | { 57 | using (Rfc2898DeriveBytes key = new(userInputData,salt, 0x3e8, HashAlgorithmName.SHA512)) 58 | { 59 | return key.GetBytes(0x20); 60 | } 61 | } 62 | 63 | private byte[] ExtractPortion(byte[] source, int offset, int length) 64 | { 65 | byte[] result = new byte[length]; 66 | Buffer.BlockCopy(source, offset, result, 0, length); 67 | return result; 68 | } 69 | 70 | [MethodImpl(MethodImplOptions.NoOptimization)] 71 | private bool ByteArraysEqual(byte[] a, byte[] b) 72 | { 73 | if (ReferenceEquals(a, b)) 74 | return true; 75 | 76 | if (a == null || b == null || a.Length != b.Length) 77 | return false; 78 | 79 | return a.SequenceEqual(b); 80 | } 81 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Security/Tokens/SigningConfigurations.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Security.Tokens; 2 | 3 | public class SigningConfigurations 4 | { 5 | public SecurityKey SecurityKey { get; } 6 | public SigningCredentials SigningCredentials { get; } 7 | 8 | public SigningConfigurations(string key) 9 | { 10 | var keyBytes = Encoding.ASCII.GetBytes(key); 11 | 12 | SecurityKey = new SymmetricSecurityKey(keyBytes); 13 | SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256Signature); 14 | } 15 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Security/Tokens/TokenHandler.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Security.Tokens; 2 | 3 | public class TokenHandler : ITokenHandler 4 | { 5 | private readonly ISet _refreshTokens = new HashSet(); 6 | 7 | private readonly TokenOptions _tokenOptions; 8 | private readonly SigningConfigurations _signingConfigurations; 9 | private readonly IPasswordHasher _passwordHaser; 10 | 11 | public TokenHandler( 12 | IOptions tokenOptionsSnapshot, 13 | SigningConfigurations signingConfigurations, 14 | IPasswordHasher passwordHaser) 15 | { 16 | _passwordHaser = passwordHaser; 17 | _tokenOptions = tokenOptionsSnapshot.Value; 18 | _signingConfigurations = signingConfigurations; 19 | } 20 | 21 | public AccessToken CreateAccessToken(User user) 22 | { 23 | var refreshToken = BuildRefreshToken(); 24 | var accessToken = BuildAccessToken(user, refreshToken); 25 | 26 | _refreshTokens.Add(new RefreshTokenWithEmail 27 | { 28 | Email = user.Email, 29 | RefreshToken = refreshToken, 30 | }); 31 | 32 | return accessToken; 33 | } 34 | 35 | public RefreshToken? TakeRefreshToken(string token, string userEmail) 36 | { 37 | if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(userEmail)) 38 | { 39 | return null; 40 | } 41 | 42 | var refreshTokenWithEmail = _refreshTokens.SingleOrDefault(t => t.RefreshToken.Token == token && t.Email == userEmail); 43 | 44 | if (refreshTokenWithEmail == null) 45 | { 46 | return null; 47 | } 48 | 49 | _refreshTokens.Remove(refreshTokenWithEmail); 50 | 51 | return refreshTokenWithEmail.RefreshToken; 52 | } 53 | 54 | public void RevokeRefreshToken(string token, string userEmail) 55 | { 56 | TakeRefreshToken(token, userEmail); 57 | } 58 | 59 | private RefreshToken BuildRefreshToken() 60 | { 61 | var refreshToken = new RefreshToken 62 | ( 63 | token: _passwordHaser.HashPassword(Guid.NewGuid().ToString()), 64 | expiration: DateTime.UtcNow.AddSeconds(_tokenOptions.RefreshTokenExpiration).Ticks 65 | ); 66 | 67 | return refreshToken; 68 | } 69 | 70 | private AccessToken BuildAccessToken(User user, RefreshToken refreshToken) 71 | { 72 | var accessTokenExpiration = DateTime.UtcNow.AddSeconds(_tokenOptions.AccessTokenExpiration); 73 | 74 | var securityToken = new JwtSecurityToken 75 | ( 76 | issuer: _tokenOptions.Issuer, 77 | audience: _tokenOptions.Audience, 78 | claims: GetClaims(user), 79 | expires: accessTokenExpiration, 80 | notBefore: DateTime.UtcNow, 81 | signingCredentials: _signingConfigurations.SigningCredentials 82 | ); 83 | 84 | var handler = new JwtSecurityTokenHandler(); 85 | var accessToken = handler.WriteToken(securityToken); 86 | 87 | return new AccessToken(accessToken, accessTokenExpiration.Ticks, refreshToken); 88 | } 89 | 90 | private IEnumerable GetClaims(User user) 91 | { 92 | var claims = new List 93 | { 94 | new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), 95 | new Claim(JwtRegisteredClaimNames.Sub, user.Email) 96 | }; 97 | 98 | foreach (var userRole in user.UserRoles) 99 | { 100 | claims.Add(new Claim(ClaimTypes.Role, userRole.Role!.Name)); 101 | } 102 | 103 | return claims; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Security/Tokens/TokenOptions.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Security.Tokens; 2 | public class TokenOptions 3 | { 4 | public string Audience { get; set; } = null!; 5 | public string Issuer { get; set; } = null!; 6 | public long AccessTokenExpiration { get; set; } 7 | public long RefreshTokenExpiration { get; set; } 8 | public string Secret { get; set; } = null!; 9 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Services/AuthenticationService.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Services; 2 | 3 | public class AuthenticationService : IAuthenticationService 4 | { 5 | private readonly IUserService _userService; 6 | private readonly IPasswordHasher _passwordHasher; 7 | private readonly ITokenHandler _tokenHandler; 8 | 9 | public AuthenticationService( 10 | IUserService userService, 11 | IPasswordHasher passwordHasher, 12 | ITokenHandler tokenHandler) 13 | { 14 | _tokenHandler = tokenHandler; 15 | _passwordHasher = passwordHasher; 16 | _userService = userService; 17 | } 18 | 19 | public async Task CreateAccessTokenAsync(string email, string password) 20 | { 21 | var user = await _userService.FindByEmailAsync(email); 22 | 23 | if (user == null || !_passwordHasher.ValidatePassword(password, user.Password)) 24 | { 25 | return new TokenResponse(false, "Invalid credentials.", null); 26 | } 27 | 28 | var token = _tokenHandler.CreateAccessToken(user); 29 | 30 | return new TokenResponse(true, null, token); 31 | } 32 | 33 | public async Task RefreshTokenAsync(string refreshToken, string userEmail) 34 | { 35 | var token = _tokenHandler.TakeRefreshToken(refreshToken, userEmail); 36 | 37 | if (token == null) 38 | { 39 | return new TokenResponse(false, "Invalid refresh token.", null); 40 | } 41 | 42 | if (token.IsExpired()) 43 | { 44 | return new TokenResponse(false, "Expired refresh token.", null); 45 | } 46 | 47 | var user = await _userService.FindByEmailAsync(userEmail); 48 | if (user == null) 49 | { 50 | return new TokenResponse(false, "Invalid refresh token.", null); 51 | } 52 | 53 | var accessToken = _tokenHandler.CreateAccessToken(user); 54 | return new TokenResponse(true, null, accessToken); 55 | } 56 | 57 | public void RevokeRefreshToken(string refreshToken, string userEmail) 58 | { 59 | _tokenHandler.RevokeRefreshToken(refreshToken, userEmail); 60 | } 61 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/Services/UserService.cs: -------------------------------------------------------------------------------- 1 | namespace JWTAPI.Services; 2 | public class UserService : IUserService 3 | { 4 | private readonly IUserRepository _userRepository; 5 | private readonly IUnitOfWork _unitOfWork; 6 | private readonly IPasswordHasher _passwordHasher; 7 | 8 | public UserService( 9 | IUserRepository userRepository, 10 | IUnitOfWork unitOfWork, 11 | IPasswordHasher passwordHasher) 12 | { 13 | _passwordHasher = passwordHasher; 14 | _unitOfWork = unitOfWork; 15 | _userRepository = userRepository; 16 | } 17 | 18 | public async Task CreateUserAsync(User user, params ApplicationRole[] userRoles) 19 | { 20 | var existingUser = await _userRepository.FindByEmailAsync(user.Email); 21 | 22 | if (existingUser != null) 23 | { 24 | return new CreateUserResponse(false, "Email already in use.", null); 25 | } 26 | 27 | user.Password = _passwordHasher.HashPassword(user.Password); 28 | 29 | await _userRepository.AddAsync(user, userRoles); 30 | await _unitOfWork.CompleteAsync(); 31 | 32 | return new CreateUserResponse(true, null, user); 33 | } 34 | 35 | public async Task FindByEmailAsync(string email) 36 | { 37 | return await _userRepository.FindByEmailAsync(email); 38 | } 39 | } -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "TokenOptions": { 3 | "Audience": "SampleAudience", 4 | "Issuer": "JWPAPI", 5 | "AccessTokenExpiration": 30, 6 | "RefreshTokenExpiration": 60, 7 | "Secret": "very_long_but_insecure_token_here_be_sure_to_use_env_var" 8 | }, 9 | "Logging": { 10 | "LogLevel": { 11 | "Default": "Debug", 12 | "System": "Information", 13 | "Microsoft": "Information" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/JWTAPI/JWTAPI/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "TokenOptions": { 3 | "Audience": "SampleAudience", 4 | "Issuer": "JWPAPI", 5 | "AccessTokenExpiration": 30, 6 | "RefreshTokenExpiration": 60, 7 | "Secret": "very_long_but_insecure_token_here_be_sure_to_use_env_var" 8 | }, 9 | "Logging": { 10 | "Debug": { 11 | "LogLevel": { 12 | "Default": "Warning" 13 | } 14 | }, 15 | "Console": { 16 | "LogLevel": { 17 | "Default": "Warning" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/JWTAPI.Tests/JWTAPI.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net7.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/JWTAPI.Tests/Security/Hashing/PasswordHasherTests.cs: -------------------------------------------------------------------------------- 1 | namespace JWTPAPI.Tests.Security.Hashing; 2 | 3 | public class PasswordHasherTests 4 | { 5 | private IPasswordHasher _passwordHasher = new PasswordHasher(); 6 | 7 | [Fact] 8 | public void Should_Throw_Exception_For_Empty_Password_When_Hashing() 9 | { 10 | var password = ""; 11 | Assert.Throws(() => _passwordHasher.HashPassword(password)); 12 | } 13 | 14 | [Fact] 15 | public void Should_Hash_Passwords() 16 | { 17 | var firstPassword = "123456"; 18 | var secondPassword = "123456"; 19 | 20 | var firstPasswordAsHash = _passwordHasher.HashPassword(firstPassword); 21 | var secondPasswordAsHash = _passwordHasher.HashPassword(secondPassword); 22 | 23 | Assert.NotSame(firstPasswordAsHash, firstPassword); 24 | Assert.NotSame(secondPasswordAsHash, secondPassword); 25 | Assert.NotSame(firstPasswordAsHash, secondPasswordAsHash); 26 | } 27 | 28 | [Fact] 29 | public void Should_Match_Password_For_Valid_Hash() 30 | { 31 | var firstPassword = "123456"; 32 | var firstPasswordAsHash = _passwordHasher.HashPassword(firstPassword); 33 | 34 | Assert.True(_passwordHasher.ValidatePassword(firstPassword, firstPasswordAsHash)); 35 | } 36 | 37 | [Fact] 38 | public void Should_Return_False_For_Different_Hasher_Passwords() 39 | { 40 | var firstPassword = "123456"; 41 | var secondPassword = "654321"; 42 | 43 | var firstPasswordAsHash = _passwordHasher.HashPassword(firstPassword); 44 | var secondPasswordAsHash = _passwordHasher.HashPassword(secondPassword); 45 | 46 | Assert.False(_passwordHasher.ValidatePassword(firstPassword, secondPasswordAsHash)); 47 | Assert.False(_passwordHasher.ValidatePassword(secondPassword, firstPasswordAsHash)); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/JWTAPI.Tests/Security/Tokens/TokenHandlerTests.cs: -------------------------------------------------------------------------------- 1 | namespace JWTPAPI.Tests.Security.Tokens; 2 | 3 | public class TokenHandlerTests 4 | { 5 | private Mock> _tokenOptions; 6 | private Mock _passwordHasher; 7 | private SigningConfigurations _signingConfigurations; 8 | private User _user; 9 | 10 | private ITokenHandler _tokenHandler; 11 | private string _testKey = "just_a_long_test_key_to_use_for_tokens"; 12 | 13 | public TokenHandlerTests() 14 | { 15 | SetupMocks(); 16 | _tokenHandler = new TokenHandler(_tokenOptions.Object, _signingConfigurations, _passwordHasher.Object); 17 | } 18 | 19 | private void SetupMocks() 20 | { 21 | _tokenOptions = new Mock>(); 22 | _tokenOptions.Setup(to => to.Value).Returns(new TokenOptions 23 | { 24 | Audience = "Testing", 25 | Issuer = "Testing", 26 | AccessTokenExpiration = 30, 27 | RefreshTokenExpiration = 60 28 | }); 29 | 30 | _passwordHasher = new Mock(); 31 | _passwordHasher.Setup(ph => ph.HashPassword(It.IsAny())).Returns("123"); 32 | 33 | _signingConfigurations = new SigningConfigurations(_testKey); 34 | 35 | _user = new User 36 | { 37 | Id = 1, 38 | Email = "test@test.com", 39 | Password = "123", 40 | UserRoles = new Collection 41 | { 42 | new UserRole 43 | { 44 | Role = new Role 45 | { 46 | Id = 1, 47 | Name = ApplicationRole.Common.ToString() 48 | } 49 | } 50 | } 51 | }; 52 | } 53 | 54 | [Fact] 55 | public void Should_Create_Access_Token() 56 | { 57 | var accessToken = _tokenHandler.CreateAccessToken(_user); 58 | 59 | Assert.NotNull(accessToken); 60 | Assert.NotNull(accessToken.RefreshToken); 61 | Assert.NotEmpty(accessToken.Token); 62 | Assert.NotEmpty(accessToken.RefreshToken.Token); 63 | Assert.True(accessToken.Expiration > DateTime.UtcNow.Ticks); 64 | Assert.True(accessToken.RefreshToken.Expiration > DateTime.UtcNow.Ticks); 65 | Assert.True(accessToken.RefreshToken.Expiration > accessToken.Expiration); 66 | } 67 | 68 | [Fact] 69 | public void Should_Take_Existing_Refresh_Token() 70 | { 71 | var accessToken = _tokenHandler.CreateAccessToken(_user); 72 | var refreshToken = _tokenHandler.TakeRefreshToken(accessToken.RefreshToken.Token, "test@test.com"); 73 | 74 | Assert.NotNull(refreshToken); 75 | Assert.Equal(accessToken.RefreshToken.Token, refreshToken.Token); 76 | Assert.Equal(accessToken.RefreshToken.Expiration, refreshToken.Expiration); 77 | } 78 | 79 | [Fact] 80 | public void Should_Return_Null_For_Empty_Refresh_Token_When_Trying_To_Take_Refresh_Token() 81 | { 82 | var refreshToken = _tokenHandler.TakeRefreshToken(string.Empty, "test@test.com"); 83 | Assert.Null(refreshToken); 84 | } 85 | 86 | [Fact] 87 | public void Should_Return_Null_For_Empty_Email_When_Trying_To_Take_Refresh_Token() 88 | { 89 | var accessToken = _tokenHandler.CreateAccessToken(_user); 90 | var refreshToken = _tokenHandler.TakeRefreshToken(accessToken.RefreshToken.Token, string.Empty); 91 | 92 | Assert.Null(refreshToken); 93 | } 94 | 95 | [Fact] 96 | public void Should_Return_Null_For_Invalid_Refresh_Token_When_Trying_To_Take_Refresh_oken() 97 | { 98 | var refreshToken = _tokenHandler.TakeRefreshToken("invalid_token", "test@test.com"); 99 | Assert.Null(refreshToken); 100 | } 101 | 102 | [Fact] 103 | public void Should_Return_Null_For_Invalid_Email_When_Trying_To_Take_Refresh_Token() 104 | { 105 | var accessToken = _tokenHandler.CreateAccessToken(_user); 106 | var refreshToken = _tokenHandler.TakeRefreshToken(accessToken.RefreshToken.Token, "admin@admin.com"); 107 | Assert.Null(refreshToken); 108 | } 109 | 110 | [Fact] 111 | public void Should_Not_Take_Refresh_Token_That_Was_Already_Taken() 112 | { 113 | var accessToken = _tokenHandler.CreateAccessToken(_user); 114 | var refreshToken = _tokenHandler.TakeRefreshToken(accessToken.RefreshToken.Token, "test@test.com"); 115 | var refreshTokenSecondTime = _tokenHandler.TakeRefreshToken(accessToken.RefreshToken.Token, "test@test.com"); 116 | 117 | Assert.NotNull(refreshToken); 118 | Assert.Null(refreshTokenSecondTime); 119 | } 120 | 121 | [Fact] 122 | public void Should_Revoke_Refresh_Token() 123 | { 124 | var accessToken = _tokenHandler.CreateAccessToken(_user); 125 | _tokenHandler.RevokeRefreshToken(accessToken.RefreshToken.Token, "test@test.com"); 126 | var refreshToken = _tokenHandler.TakeRefreshToken(accessToken.RefreshToken.Token, "test@test.com"); 127 | 128 | Assert.Null(refreshToken); 129 | } 130 | } -------------------------------------------------------------------------------- /tests/JWTAPI.Tests/Services/AuthenticationServiceTests.cs: -------------------------------------------------------------------------------- 1 | namespace JWTPAPI.Tests.Services; 2 | public class AuthenticationServiceTests 3 | { 4 | private bool _calledRefreshToken = false; 5 | 6 | private Mock _userService; 7 | private Mock _passwordHasher; 8 | private Mock _tokenHandler; 9 | 10 | private IAuthenticationService _authenticationService; 11 | 12 | public AuthenticationServiceTests() 13 | { 14 | SetupMocks(); 15 | _authenticationService = new AuthenticationService(_userService.Object, _passwordHasher.Object, _tokenHandler.Object); 16 | } 17 | 18 | private void SetupMocks() 19 | { 20 | _userService = new Mock(); 21 | _userService.Setup(u => u.FindByEmailAsync("invalid@invalid.com")) 22 | .Returns(Task.FromResult(null)); 23 | 24 | _userService.Setup(u => u.FindByEmailAsync("test@test.com")) 25 | .ReturnsAsync(new User 26 | { 27 | Id = 1, 28 | Email = "test@test.com", 29 | Password = "123", 30 | UserRoles = new Collection 31 | { 32 | new UserRole 33 | { 34 | UserId = 1, 35 | RoleId = 1, 36 | Role = new Role 37 | { 38 | Id = 1, 39 | Name = ApplicationRole.Common.ToString() 40 | } 41 | } 42 | } 43 | }); 44 | 45 | _passwordHasher = new Mock(); 46 | _passwordHasher.Setup(ph => ph.ValidatePassword(It.IsAny(), It.IsAny())) 47 | .Returns((password, hash) => password == hash); 48 | 49 | _tokenHandler = new Mock(); 50 | _tokenHandler.Setup(h => h.CreateAccessToken(It.IsAny())) 51 | .Returns(new AccessToken 52 | ( 53 | token: "abc", 54 | expiration: DateTime.UtcNow.AddSeconds(30).Ticks, 55 | refreshToken: new RefreshToken 56 | ( 57 | token: "abc", 58 | expiration: DateTime.UtcNow.AddSeconds(60).Ticks 59 | ) 60 | ) 61 | ); 62 | 63 | _tokenHandler.Setup(h => h.TakeRefreshToken("abc", It.IsAny())) 64 | .Returns(new RefreshToken("abc", DateTime.UtcNow.AddSeconds(60).Ticks)); 65 | 66 | _tokenHandler.Setup(h => h.TakeRefreshToken("expired", It.IsAny())) 67 | .Returns(new RefreshToken("expired", DateTime.UtcNow.AddSeconds(-60).Ticks)); 68 | 69 | _tokenHandler.Setup(h => h.TakeRefreshToken("invalid", It.IsAny())) 70 | .Returns(null); 71 | 72 | _tokenHandler.Setup(h => h.RevokeRefreshToken("abc", It.IsAny())) 73 | .Callback(() => _calledRefreshToken = true); 74 | } 75 | 76 | [Fact] 77 | public async Task Should_Create_Access_Token_For_Valid_Credentials() 78 | { 79 | var response = await _authenticationService.CreateAccessTokenAsync("test@test.com", "123"); 80 | 81 | Assert.NotNull(response); 82 | Assert.True(response.Success); 83 | Assert.NotNull(response.Token); 84 | Assert.NotNull(response.Token.RefreshToken); 85 | Assert.Equal("abc", response.Token.Token); 86 | Assert.Equal("abc", response.Token.RefreshToken.Token); 87 | Assert.False(response.Token.IsExpired()); 88 | Assert.False(response.Token.RefreshToken.IsExpired()); 89 | } 90 | 91 | [Fact] 92 | public async Task Should_Not_Create_Access_Token_For_Non_Existing_User() 93 | { 94 | var response = await _authenticationService.CreateAccessTokenAsync("invalid@invalid.com", "123"); 95 | 96 | Assert.NotNull(response); 97 | Assert.False(response.Success); 98 | Assert.Equal("Invalid credentials.", response.Message); 99 | } 100 | 101 | [Fact] 102 | public async Task Should_Not_Create_Access_Token_For_Invalid_Password() 103 | { 104 | var response = await _authenticationService.CreateAccessTokenAsync("invalid@invalid.com", "321"); 105 | 106 | Assert.NotNull(response); 107 | Assert.False(response.Success); 108 | Assert.Equal("Invalid credentials.", response.Message); 109 | } 110 | 111 | [Fact] 112 | public async Task Should_Refresh_Token_For_Valid_Refresh_Token() 113 | { 114 | var response = await _authenticationService.RefreshTokenAsync("abc", "test@test.com"); 115 | 116 | Assert.NotNull(response); 117 | Assert.True(response.Success); 118 | Assert.Equal("abc", response.Token.Token); 119 | } 120 | 121 | [Fact] 122 | public async Task Should_Not_Refresh_Token_When_Token_Is_Expired() 123 | { 124 | var response = await _authenticationService.RefreshTokenAsync("expired", "test@test.com"); 125 | 126 | Assert.NotNull(response); 127 | Assert.False(response.Success); 128 | Assert.Equal("Expired refresh token.", response.Message); 129 | } 130 | 131 | [Fact] 132 | public async Task Should_Not_Refresh_Token_For_Invalid_User_Data() 133 | { 134 | var response = await _authenticationService.RefreshTokenAsync("invalid", "test@test.com"); 135 | 136 | Assert.NotNull(response); 137 | Assert.False(response.Success); 138 | Assert.Equal("Invalid refresh token.", response.Message); 139 | } 140 | 141 | [Fact] 142 | public void Should_Revoke_Refresh_Token() 143 | { 144 | _authenticationService.RevokeRefreshToken("abc", "test@test.com"); 145 | 146 | Assert.True(_calledRefreshToken); 147 | } 148 | } -------------------------------------------------------------------------------- /tests/JWTAPI.Tests/Services/UserServiceTests.cs: -------------------------------------------------------------------------------- 1 | namespace JWTPAPI.Tests.Services; 2 | 3 | public class UserServiceTests 4 | { 5 | private Mock _passwordHasher; 6 | private Mock _userRepository; 7 | private Mock _unitOfWork; 8 | 9 | private IUserService _userService; 10 | 11 | public UserServiceTests() 12 | { 13 | SetupMocks(); 14 | _userService = new UserService(_userRepository.Object, _unitOfWork.Object, _passwordHasher.Object); 15 | } 16 | 17 | private void SetupMocks() 18 | { 19 | _passwordHasher = new Mock(); 20 | _passwordHasher.Setup(ph => ph.HashPassword(It.IsAny())).Returns("123"); 21 | 22 | _userRepository = new Mock(); 23 | _userRepository.Setup(r => r.FindByEmailAsync("test@test.com")) 24 | .ReturnsAsync(new User { Id = 1, Email = "test@test.com", UserRoles = new Collection() }); 25 | 26 | _userRepository.Setup(r => r.FindByEmailAsync("secondtest@secondtest.com")) 27 | .Returns(Task.FromResult(null)); 28 | 29 | _userRepository.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); 30 | 31 | _unitOfWork = new Mock(); 32 | _unitOfWork.Setup(u => u.CompleteAsync()).Returns(Task.CompletedTask); 33 | } 34 | 35 | [Fact] 36 | public async Task Should_Create_Non_Existing_User() 37 | { 38 | var user = new User { Email = "mytestuser@mytestuser.com", Password = "123", UserRoles = new Collection() }; 39 | 40 | var response = await _userService.CreateUserAsync(user, ApplicationRole.Common); 41 | 42 | Assert.NotNull(response); 43 | Assert.True(response.Success); 44 | Assert.Equal(user.Email, response.User.Email); 45 | Assert.Equal(user.Password, response.User.Password); 46 | } 47 | 48 | [Fact] 49 | public async Task Should_Not_Create_User_When_Email_Is_Alreary_In_Use() 50 | { 51 | var user = new User { Email = "test@test.com", Password = "123", UserRoles = new Collection() }; 52 | 53 | var response = await _userService.CreateUserAsync(user, ApplicationRole.Common); 54 | 55 | Assert.False(response.Success); 56 | Assert.Equal("Email already in use.", response.Message); 57 | } 58 | 59 | [Fact] 60 | public async Task Should_Find_Existing_User_By_Email() 61 | { 62 | var user = await _userService.FindByEmailAsync("test@test.com"); 63 | Assert.NotNull(user); 64 | Assert.Equal("test@test.com", user.Email); 65 | } 66 | 67 | [Fact] 68 | public async Task Should_Return_Null_When_Trying_To_Find_User_By_Invalid_Email() 69 | { 70 | var user = await _userService.FindByEmailAsync("secondtest@secondtest.com"); 71 | Assert.Null(user); 72 | } 73 | } -------------------------------------------------------------------------------- /tests/JWTAPI.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using JWTAPI.Core.Models; 2 | global using JWTAPI.Core.Repositories; 3 | global using JWTAPI.Core.Security.Hashing; 4 | global using JWTAPI.Core.Security.Tokens; 5 | global using JWTAPI.Core.Services; 6 | global using JWTAPI.Security.Hashing; 7 | global using JWTAPI.Security.Tokens; 8 | global using JWTAPI.Services; 9 | global using Microsoft.Extensions.Options; 10 | global using Moq; 11 | global using System; 12 | global using System.Collections.ObjectModel; 13 | global using System.Threading.Tasks; 14 | global using Xunit; 15 | --------------------------------------------------------------------------------