정보를 담는 DATABASE 는 Microsoft.EntityFrameworkCore.InMemory 를 활용하여 테스트 목적을 위해 ENTITY FRAMEWORK CORE 에서 제공한 IN-MEMORY 데이터베이스 제공자 입니다. 즉, 실제 db 서버를 설치하지 않고도 Entity Framework Core가 메모리 내 데이터베이스를 만들고 연결할 수 있도록 하는 EF Core InMemory 데이터베이스 공급자를 사용하도록 구성되었습니다.
WEB API 컨트롤러를 보면, authenticate 와 refresh-token 를 호출하는 경로는 AllowAnonymous 로써, 비인증자도 접근이 가능합니다. 나머지는 전부 JWT TOKEN 값을 전부 체크하여 인증 된 경우에만 접근이 가능하도록 Authorize 되어 있습니다.
그리고 XSS(교차 사이트 스크립팅) 공격 과 CSRF(교차 사이트 요청 위조) 공격에 사용되지 않도록 하기 위해 보안에 신경을 쓴 흔적이 있는 코드 입니다. ^^
namespace WebApi.Controllers;
using Microsoft.AspNetCore.Mvc;
using WebApi.Authorization;
using WebApi.Models.Users;
using WebApi.Services;
[Authorize]
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
private IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[AllowAnonymous]
[HttpPost("authenticate")]
public IActionResult Authenticate(AuthenticateRequest model)
{
var response = _userService.Authenticate(model, ipAddress());
setTokenCookie(response.RefreshToken);
return Ok(response);
}
[AllowAnonymous]
[HttpPost("refresh-token")]
public IActionResult RefreshToken()
{
var refreshToken = Request.Cookies["refreshToken"];
var response = _userService.RefreshToken(refreshToken, ipAddress());
setTokenCookie(response.RefreshToken);
return Ok(response);
}
[HttpPost("revoke-token")]
public IActionResult RevokeToken(RevokeTokenRequest model)
{
// accept refresh token in request body or cookie
var token = model.Token ?? Request.Cookies["refreshToken"];
if (string.IsNullOrEmpty(token))
return BadRequest(new { message = "Token is required" });
_userService.RevokeToken(token, ipAddress());
return Ok(new { message = "Token revoked" });
}
[HttpGet]
public IActionResult GetAll()
{
var users = _userService.GetAll();
return Ok(users);
}
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
var user = _userService.GetById(id);
return Ok(user);
}
[HttpGet("{id}/refresh-tokens")]
public IActionResult GetRefreshTokens(int id)
{
var user = _userService.GetById(id);
return Ok(user.RefreshTokens);
}
// helper methods
private void setTokenCookie(string token)
{
// append cookie with refresh token to the http response
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Expires = DateTime.UtcNow.AddDays(7)
};
Response.Cookies.Append("refreshToken", token, cookieOptions);
}
private string ipAddress()
{
// get source ip address for the current request
if (Request.Headers.ContainsKey("X-Forwarded-For"))
return Request.Headers["X-Forwarded-For"];
else
return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();
}
}
Refresh Token 값은 http only 로 cookie 가 되어 있어서 아래 처럼 Request 객체를 통해 데이터를 얻을 수 있습니다.
var refreshToken = Request.Cookies["refreshToken"];
비밀번호는 BCrypt.Net.BCrypt.HashPassword() 함수를 통해 암호화하여 보관합니다.
var context = scope.ServiceProvider.GetRequiredService<DataContext>();
var testUser = new User
{
FirstName = "Test",
LastName = "User",
Username = "test",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("test")
};
비밀번호 비교는 아이디를 기반으로 조회하여 BCrypt.Verify(입력한 비빌번호값, 암호화된 암호화값) 함수를 통해 비교해서 true, false 를 알아 낼수 있습니다.
var user = _context.Users.SingleOrDefault(x => x.Username == model.Username);
// validate
if (user == null || !BCrypt.Verify(model.Password, user.PasswordHash))
throw new AppException("Username or password is incorrect");
토큰 생성은 Microsoft.IdentityModel.Tokens 에서 제공한 SecurityTokenDescriptor 객체에 담아 token 을 CreateToken() 함수를 통해 생성합니다. 만기 기간은 15분, SigningCredentials 은 Sha256 암호화 기반으로 sign 키값을 넣어서 생성합니다.
public string GenerateJwtToken(User user)
{
// generate token that is valid for 15 minutes
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[] { new Claim("id", user.Id.ToString()) }),
Expires = DateTime.UtcNow.AddMinutes(15),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
토큰값을 통해 복원하여 cliam 에 담겨져 있는 id 정보를 추출하여 반환해 주는 부분입니다.
public int? ValidateJwtToken(string token)
{
if (token == null)
return null;
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
try
{
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false,
// set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later)
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value);
// return user id from JWT token if validation successful
return userId;
}
catch
{
// return null if validation fails
return null;
}
}
Refresh token 생성은 base64 로 랜덤 값을 생성하여 기존에 발행한 토큰값과 동일한지 체크하고 유일한 token 이면 이를 반환합니다. 동일하면 재귀함수 처럼 다시 실행하여 유일값이 나올때 까지 실행합니다. Refresh token 의 만기 기간은 7일 입니다.
public RefreshToken GenerateRefreshToken(string ipAddress)
{
var refreshToken = new RefreshToken
{
Token = getUniqueToken(),
// token is valid for 7 days
Expires = DateTime.UtcNow.AddDays(7),
Created = DateTime.UtcNow,
CreatedByIp = ipAddress
};
return refreshToken;
string getUniqueToken()
{
// token is a cryptographically strong random sequence of values
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
// ensure token is unique by checking against db
var tokenIsUnique = !_context.Users.Any(u => u.RefreshTokens.Any(t => t.Token == token));
if (!tokenIsUnique)
return getUniqueToken();
return token;
}
}