module auth.service; import handy_httpd; import handy_httpd.components.optional; import handy_httpd.handlers.filtered_handler; import slf4d; import auth.model; import auth.data; import auth.data_impl_fs; const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config! User createNewUser(UserRepository repo, string username, string password) { import botan.passhash.bcrypt : generateBcrypt; import botan.rng.auto_rng; RandomNumberGenerator rng = new AutoSeededRNG(); string passwordHash = generateBcrypt(password, rng, 12); return repo.createUser(username, passwordHash); } void deleteUser(User user, UserRepository repo) { repo.deleteByUsername(user.username); } /** * Generates a new JWT access token for a user. * Params: * user = The user to generate the token for. * Returns: The token. */ string generateAccessToken(User user) { import jwt.jwt : Token; import jwt.algorithms : JWTAlgorithm; import std.datetime; const TIMEOUT_MINUTES = 30; Token token = new Token(JWTAlgorithm.HS512); token.claims.aud("finnow-api"); token.claims.sub(user.username); token.claims.exp(Clock.currTime().toUnixTime() + TIMEOUT_MINUTES * 60); token.claims.iss("finnow-api"); return token.encode(SECRET); } /** * A request filter that only permits authenticated requests to be processed. */ class TokenAuthenticationFilter : HttpRequestFilter { private static const AUTH_METADATA_KEY = "AuthContext"; private immutable string secret; this(string secret) { this.secret = secret; } void apply(ref HttpRequestContext ctx, FilterChain fc) { Optional!AuthContext optionalAuth = validateAuthContext(ctx); if (!optionalAuth.isNull) { ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.value; fc.doFilter(ctx); // Only continue the filter chain if a valid auth context was obtained. } } } /// Information about the current request's authentication status. class AuthContext { string token; User user; this(string token, User user) { this.token = token; this.user = user; } } /** * Helper method to get the authentication context from a request context * that was previously passed through this filter. * Params: * ctx = The request context to get. * Returns: The auth context that has been set. */ AuthContext getAuthContext(in HttpRequestContext ctx) { return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY]; } private Optional!AuthContext validateAuthContext(ref HttpRequestContext ctx) { import jwt.jwt : verify, Token; import jwt.algorithms : JWTAlgorithm; import std.typecons; const HEADER_NAME = "Authorization"; if (!ctx.request.headers.contains(HEADER_NAME)) { return setUnauthorized(ctx, "Missing Authorization header."); } string authorizationHeader = ctx.request.headers.getFirst(HEADER_NAME).orElse(""); if (authorizationHeader.length < 7 || authorizationHeader[0..7] != "Bearer ") { return setUnauthorized(ctx, "Invalid Authorization header format. Expected bearer token."); } string rawToken = authorizationHeader[7..$]; try { Token token = verify(rawToken, SECRET, [JWTAlgorithm.HS512]); string username = token.claims.sub; UserRepository userRepo = new FileSystemUserRepository(); Optional!User optionalUser = userRepo.findByUsername(username); if (optionalUser.isNull) { return setUnauthorized(ctx, "User does not exist."); } return Optional!AuthContext.of(new AuthContext(rawToken, optionalUser.value)); } catch (Exception e) { warn("Failed to verify user token.", e); return setUnauthorized(ctx, "Invalid or malformed token."); } } private Optional!AuthContext setUnauthorized(ref HttpRequestContext ctx, string msg) { ctx.response.status = HttpStatus.UNAUTHORIZED; ctx.response.writeBodyString(msg); return Optional!AuthContext.empty; }