2023-08-22 14:05:26 +00:00
|
|
|
/**
|
|
|
|
* Logic for user authentication.
|
|
|
|
*/
|
2023-08-16 14:37:39 +00:00
|
|
|
module auth;
|
|
|
|
|
|
|
|
import handy_httpd;
|
|
|
|
import handy_httpd.handlers.filtered_handler;
|
2023-08-17 15:55:05 +00:00
|
|
|
import slf4d;
|
|
|
|
|
2023-08-24 19:37:25 +00:00
|
|
|
import std.typecons;
|
|
|
|
|
2023-08-22 14:05:26 +00:00
|
|
|
import data.user;
|
2023-08-17 15:55:05 +00:00
|
|
|
|
2023-08-24 19:37:25 +00:00
|
|
|
immutable string AUTH_METADATA_KEY = "AuthContext";
|
|
|
|
|
2023-08-22 14:05:26 +00:00
|
|
|
/**
|
|
|
|
* Generates a new access token for an authenticated user.
|
|
|
|
* Params:
|
|
|
|
* user = The user to generate a token for.
|
|
|
|
* secret = The secret key to use to sign the token.
|
|
|
|
* Returns: The base-64 encoded and signed token string.
|
|
|
|
*/
|
|
|
|
string generateToken(in User user, in string secret) {
|
|
|
|
import jwt.jwt : Token;
|
|
|
|
import jwt.algorithms : JWTAlgorithm;
|
|
|
|
import std.datetime;
|
2023-08-17 15:55:05 +00:00
|
|
|
Token token = new Token(JWTAlgorithm.HS512);
|
|
|
|
token.claims.aud("litelist-api");
|
|
|
|
token.claims.sub(user.username);
|
|
|
|
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
|
|
|
|
token.claims.iss("litelist-api");
|
2023-08-24 19:37:25 +00:00
|
|
|
return token.encode(secret);
|
2023-08-17 15:55:05 +00:00
|
|
|
}
|
|
|
|
|
2023-08-22 14:05:26 +00:00
|
|
|
void sendUnauthenticatedResponse(ref HttpResponse resp) {
|
2023-08-17 15:55:05 +00:00
|
|
|
resp.setStatus(HttpStatus.UNAUTHORIZED);
|
|
|
|
resp.writeBodyString("Invalid credentials.");
|
2023-08-16 14:37:39 +00:00
|
|
|
}
|
|
|
|
|
2023-08-22 14:05:26 +00:00
|
|
|
string loadTokenSecret() {
|
|
|
|
import std.file : exists;
|
|
|
|
import d_properties;
|
|
|
|
if (exists("application.properties")) {
|
|
|
|
Properties props = Properties("application.properties");
|
|
|
|
if (props.has("secret")) {
|
|
|
|
return props.get("secret");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
error("Couldn't load token secret from application.properties. Using insecure secret.");
|
|
|
|
return "supersecret";
|
|
|
|
}
|
|
|
|
|
2023-08-24 19:37:25 +00:00
|
|
|
class AuthContext {
|
2023-08-16 14:37:39 +00:00
|
|
|
string token;
|
|
|
|
User user;
|
|
|
|
|
2023-08-24 19:37:25 +00:00
|
|
|
this(string token, User user) {
|
|
|
|
this.token = token;
|
|
|
|
this.user = user;
|
2023-08-16 14:37:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-17 15:55:05 +00:00
|
|
|
/**
|
|
|
|
* Validates any request that should be authenticated with an access token,
|
|
|
|
* and sets the AuthContextHolder's context if the user is authenticated.
|
|
|
|
* Otherwise, sends an appropriate "unauthorized" response.
|
|
|
|
* Params:
|
|
|
|
* ctx = The request context to validate.
|
2023-08-22 14:05:26 +00:00
|
|
|
* secret = The secret key that should have been used to sign the token.
|
2023-08-24 19:37:25 +00:00
|
|
|
* Returns: The AuthContext if authentication is successful, or null otherwise.
|
2023-08-17 15:55:05 +00:00
|
|
|
*/
|
2023-08-24 19:37:25 +00:00
|
|
|
Nullable!AuthContext validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) {
|
2023-08-22 14:05:26 +00:00
|
|
|
import jwt.jwt : verify, Token;
|
|
|
|
import jwt.algorithms : JWTAlgorithm;
|
|
|
|
import std.typecons;
|
|
|
|
|
2023-08-16 14:37:39 +00:00
|
|
|
immutable HEADER_NAME = "Authorization";
|
2024-01-25 01:28:56 +00:00
|
|
|
if (!ctx.request.headers.contains(HEADER_NAME)) {
|
2023-08-17 15:55:05 +00:00
|
|
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
|
|
|
ctx.response.writeBodyString("Missing Authorization header.");
|
2023-08-24 19:37:25 +00:00
|
|
|
return Nullable!AuthContext.init;
|
2023-08-17 15:55:05 +00:00
|
|
|
}
|
2024-01-25 01:28:56 +00:00
|
|
|
string authHeader = ctx.request.headers.getFirst(HEADER_NAME).orElse("");
|
2023-08-17 15:55:05 +00:00
|
|
|
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
|
|
|
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
|
|
|
ctx.response.writeBodyString("Invalid bearer token authorization header.");
|
2023-08-24 19:37:25 +00:00
|
|
|
return Nullable!AuthContext.init;
|
2023-08-17 15:55:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
string rawToken = authHeader[7 .. $];
|
|
|
|
string username;
|
|
|
|
try {
|
2023-08-24 19:37:25 +00:00
|
|
|
Token token = verify(rawToken, secret, [JWTAlgorithm.HS512]);
|
2023-08-17 15:55:05 +00:00
|
|
|
username = token.claims.sub;
|
|
|
|
} catch (Exception e) {
|
|
|
|
warn("Failed to verify user token.", e);
|
2023-08-24 19:37:25 +00:00
|
|
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
|
|
|
ctx.response.writeBodyString("Invalid or malformed token.");
|
|
|
|
return Nullable!AuthContext.init;
|
2023-08-17 15:55:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
Nullable!User user = userDataSource.getUser(username);
|
|
|
|
if (user.isNull) {
|
|
|
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
|
|
|
ctx.response.writeBodyString("User does not exist.");
|
2023-08-24 19:37:25 +00:00
|
|
|
return Nullable!AuthContext.init;
|
2023-08-17 15:55:05 +00:00
|
|
|
}
|
|
|
|
|
2023-08-24 19:37:25 +00:00
|
|
|
return nullable(new AuthContext(rawToken, user.get));
|
2023-08-17 15:55:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
class TokenFilter : HttpRequestFilter {
|
2023-08-22 14:05:26 +00:00
|
|
|
private immutable string secret;
|
|
|
|
|
|
|
|
this(string secret) {
|
|
|
|
this.secret = secret;
|
|
|
|
}
|
|
|
|
|
2023-08-16 14:37:39 +00:00
|
|
|
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
2023-08-24 19:37:25 +00:00
|
|
|
Nullable!AuthContext optionalAuth = validateAuthenticatedRequest(ctx, this.secret);
|
|
|
|
if (!optionalAuth.isNull) {
|
|
|
|
ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.get();
|
|
|
|
filterChain.doFilter(ctx); // Only continue the filter chain if we're authenticated.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class AdminFilter : HttpRequestFilter {
|
|
|
|
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
|
|
|
AuthContext authCtx = getAuthContextOrThrow(ctx);
|
|
|
|
if (authCtx.user.admin) {
|
|
|
|
filterChain.doFilter(ctx);
|
|
|
|
} else {
|
|
|
|
ctx.response.setStatus(HttpStatus.FORBIDDEN);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
AuthContext getAuthContextOrThrow(ref HttpRequestContext ctx) {
|
|
|
|
if (AUTH_METADATA_KEY in ctx.metadata) {
|
|
|
|
if (auto authCtx = cast(AuthContext) ctx.metadata[AUTH_METADATA_KEY]) {
|
|
|
|
return authCtx;
|
|
|
|
}
|
2023-08-16 14:37:39 +00:00
|
|
|
}
|
2023-08-24 19:37:25 +00:00
|
|
|
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated.");
|
2023-08-16 14:37:39 +00:00
|
|
|
}
|