finnow/finnow-api/source/auth/service.d

122 lines
4.0 KiB
D

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;
}