/* neon-auth - Authentication Extensions to Neon Copyright (C) 2024 Ulrich Hilger This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package de.uhilger.neon.auth; import com.sun.net.httpserver.Authenticator; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpPrincipal; import de.uhilger.neon.HttpResponder; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import java.io.IOException; import java.security.Key; import java.util.Date; import java.util.Map; /** * Die Klasse Authenticator authentifziert gemäß OAuth-Spezifikation * "The OAuth 2.0 Authorization Framework: Bearer Token Usage" * https://datatracker.ietf.org/doc/html/rfc6750 * * weitere Info-Links * https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/ * https://swagger.io/docs/specification/authentication/bearer-authentication/ * * @author Ulrich Hilger * @version 1, 08.06.2021 */ public class BearerAuthenticator extends Authenticator /* implements Element*/ { public static final String STR_SLASH = "/"; public static final String STR_BLANK = " "; public static final String STR_COMMA = ","; public static final String STR_EMPTY = ""; public static final String STR_EQUAL = "="; public static final String STR_QUOTE = "\""; public static final String AUTHORIZATION = "Authorization"; public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; public static final String BEARER = "Bearer"; public static final String REALM = "Realm"; public static final String ERROR = "error"; public static final String ERROR_DESC = "error_description"; public static final String MSG_INVALID_TOKEN = "invalid_token"; public static final String MSG_TOKEN_EXPIRED = "The access token expired"; /** Status code Unauthorized (401) */ public static final int SC_UNAUTHORIZED = 401; /** der Schluessel zur Signatur von Tokens */ protected final Key key; public BearerAuthenticator() { key = Keys.secretKeyFor(SignatureAlgorithm.HS256); } @Override public Result authenticate(HttpExchange exchange) { Map attributes = exchange.getHttpContext().getAttributes(); String login = (String) attributes.getOrDefault("loginRoute", "/login"); String refresh = (String) attributes.getOrDefault("refreshRoute", "/refresh"); String route = exchange.getRequestURI().getPath().substring(exchange.getHttpContext().getPath().length()); if(route.endsWith(login) || route.endsWith(refresh)) { Result result = new Authenticator.Success(new HttpPrincipal("login", "realm")); return result; } else { String jwt = getToken(exchange); if (jwt.equals(STR_EMPTY)) { return unauthorized(exchange); } else { try { Claims body = Jwts .parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(jwt) .getBody(); Date issueDate = body.getIssuedAt(); Date refreshDate = Date.from(issueDate .toInstant() .plusSeconds(Long.parseLong( (String) attributes.getOrDefault("refreshSeconds", "3600")))); Date now = new Date(); if (now.before(refreshDate)) { String jwtUserId = body.getSubject(); try { HttpPrincipal pp = new HttpPrincipal(jwtUserId, "realm"); Result result = new Authenticator.Success(pp); return result; } catch (Exception ex) { return new Authenticator.Failure(SC_UNAUTHORIZED); } } else { return unauthorizedExpired(exchange); } } catch (JwtException ex) { // we *cannot* use the JWT as intended by its creator // z.B. Expiration Date ueberschritten oder Key passt nicht //sessions.remove(jwt); return unauthorized(exchange); } } } } /** * * Hinweis: Die Methode setExpiration des JWT laesst einen Token * am entsprechenden Zeitpunkt ungueltig werden. Ein Lesen des Token * laeuft dann auf einen Fehler und man kann nicht ermitteln, ob der * Fehler wegen des Ablaufs des Token oder aus anderem Grund entstand. * * Eine Gueltigkeitsdauer, die bei Ablauf einen Refresh des Tokens * siganlisieren soll, kann nicht mit diesem Ablaufdatum realisiert werden. * * @param userId * @return */ public String createToken(String userId, long expire) { Date now = new Date(); Date exp = Date.from(now.toInstant().plusSeconds(expire)); String jws = Jwts .builder() .setSubject(userId) .setIssuedAt(now) .setExpiration(exp) .signWith(key) .compact(); return jws; } /** * Bis auf weiteres wird hier der Token nur darauf geprueft, ob er ein * gueltiger JWT ist, der mit dem Schluessel dieses Authenticators * erzeugt wurde. * * Evtl. wird es in Zukunft noch noetig, weitere Kriterien einzubauen, * z.B. ob er zu einem Token aussgegeben wurde, der noch gilt. * * @param refreshToken * @return die Benutzerkennung aus dem Refresh Token, wenn der * Refresh Token fuer einen Token Refresh akzeptiert wird, null wenn nicht. */ public String validateRefreshToken(String refreshToken) { try { Claims body = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(refreshToken).getBody(); String jwtUserId = body.getSubject(); return jwtUserId; } catch (JwtException ex) { // we *cannot* use the JWT as intended by its creator // z.B. Expiration Date ueberschritten oder Key passt nicht //sessions.remove(jwt); return null; } } /** * Den Token aus dem Authorization Header lesen * * z.B. Authorization: Bearer mF_9.B5f-4.1JqM * * @param exchange * @return der Token oder STR_EMPTY, falls * kein Token gefunden wurde */ private String getToken(HttpExchange exchange) { String token = STR_EMPTY; Headers headers = exchange.getRequestHeaders(); String auth = headers.getFirst(AUTHORIZATION); if(auth != null) { String[] parts = auth.split(BEARER); if(parts != null && parts.length > 1) { token = parts[1].trim(); } } else { // unschoen, aber fuer Image-Links in HTML-Inhalten // mit Query versuchen // z.B. // GET /resource?access_token=mF_9.B5f-4.1JqM HTTP/1.1 // Host: server.example.com String query = exchange.getRequestURI().getQuery(); if(query != null && query.toLowerCase().contains("access_token")) { String[] parts = query.split("&"); for(String part : parts) { String[] keyVal = part.split("="); if(keyVal[0].equalsIgnoreCase("access_token")) { token = keyVal[1].trim(); } } } } return token; } /** * Wenn die Anfrage eine Token enthaelt, der gemaess setRefreshSeconds * abgelaufen ist und einen Refresh erfordert. * * HTTP/1.1 401 Unauthorized * WWW-Authenticate: Bearer realm="example", * error="invalid_token", * error_description="The access token expired" * * @param exchange * @return */ protected Result unauthorizedExpired(HttpExchange exchange) { StringBuilder sb = new StringBuilder(); sb.append(BEARER); sb.append(STR_BLANK); sb.append(REALM); sb.append(STR_EQUAL); sb.append(STR_QUOTE); //sb.append(getWWWAuthRealm(exchange)); sb.append(exchange.getHttpContext().getPath()); sb.append(STR_QUOTE); sb.append(STR_COMMA); sb.append(STR_BLANK); sb.append(ERROR); sb.append(STR_EQUAL); sb.append(STR_QUOTE); sb.append(MSG_INVALID_TOKEN); sb.append(STR_QUOTE); sb.append(STR_COMMA); sb.append(STR_BLANK); sb.append(ERROR_DESC); sb.append(STR_EQUAL); sb.append(STR_QUOTE); sb.append(MSG_TOKEN_EXPIRED); sb.append(STR_QUOTE); Headers headers = exchange.getResponseHeaders(); headers.add(WWW_AUTHENTICATE, sb.toString()); HttpResponder r = new HttpResponder(); try { r.antwortSenden(exchange, SC_UNAUTHORIZED, STR_EMPTY); } catch (IOException ex) { //logger.log(Level.SEVERE, null, ex); } return new Authenticator.Retry(SC_UNAUTHORIZED); } /** * Wenn die Anfrage keinen Token enthaelt * * HTTP/1.1 401 Unauthorized * WWW-Authenticate: Bearer realm="example" * * @param exchange * @return das Ergebnis */ protected Result unauthorized(HttpExchange exchange) { StringBuilder sb = new StringBuilder(); sb.append(BEARER); sb.append(STR_BLANK); sb.append(REALM); sb.append(STR_EQUAL); sb.append(STR_QUOTE); //sb.append(getWWWAuthRealm(exchange)); sb.append(exchange.getHttpContext().getPath()); sb.append(STR_QUOTE); Headers headers = exchange.getResponseHeaders(); headers.add(WWW_AUTHENTICATE, sb.toString()); HttpResponder r = new HttpResponder(); try { r.antwortSenden(exchange, SC_UNAUTHORIZED, STR_EMPTY); } catch (IOException ex) { //logger.log(Level.SEVERE, null, ex); } return new Authenticator.Retry(SC_UNAUTHORIZED); } }