/* http-oauth - OAuth Extensions to jdk.httpserver Copyright (C) 2021 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.httpserver.oauth; 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.httpserver.auth.realm.Realm; import de.uhilger.httpserver.base.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.logging.Level; import java.util.logging.Logger; /** * 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 { /** Der Logger dieser Klasse */ private static final Logger logger = Logger.getLogger(BearerAuthenticator.class.getName()); 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; private Realm realm; private String wwwAuthRealm; private String principalAuthRealm; /** der Schluessel zur Signatur von Tokens */ protected final Key key; private long expireSeconds; private long refreshSeconds; private long refreshExpire; public BearerAuthenticator() { key = Keys.secretKeyFor(SignatureAlgorithm.HS256); } @Override public Result authenticate(HttpExchange exchange) { logger.info(exchange.getRequestURI().toString()); 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(refreshSeconds)); Date now = new Date(); if(now.before(refreshDate)) { String jwtUserId = body.getSubject(); try { HttpPrincipal pp = new HttpPrincipal(jwtUserId, getPrincipalAuthRealm(exchange)); Result result = new Authenticator.Success(pp); return result; } catch (Exception ex) { logger.log(Level.SEVERE, null, 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); } } } /** * Anmelden * * @param userId die Kennung des Benutzers * @param password das Kennwort des Benutzers * @return Token oder null, wenn die Anmeldung misslang */ public LoginResponse login(String userId, String password) { if (realm.isValid(userId, password)) { LoginResponse r = new LoginResponse(); String token = createToken(userId, expireSeconds); r.setToken(token); r.setRefreshToken(createToken(userId, refreshExpire)); r.setExpiresIn(expireSeconds); return r; } else { return null; } } public LoginResponse refresh(String refreshToken) { String userId = validateRefreshToken(refreshToken); if (userId != null) { LoginResponse r = new LoginResponse(); String token = createToken(userId, expireSeconds); r.setToken(token); r.setRefreshToken(createToken(userId, refreshExpire)); r.setExpiresIn(expireSeconds); return r; } else { return null; } } /** * * 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 */ private 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; } /** * Den Eintrag fuer das 'realm'-Attribut * im WWW-Authenticate Header bestimmen * * @param exchange * @return den Ausdruck fuer den WWW-Authenticate Header */ protected String getWWWAuthRealm(HttpExchange exchange) { return wwwAuthRealm; } /** * Den Namen des Realms bestimmen, wie er fuer authentifizierte Benutzer * vom Principal ausgegeben wird * * @param exchange * @return den Namen des Realms */ protected String getPrincipalAuthRealm(HttpExchange exchange) { return principalAuthRealm; } /** * 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(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(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); } public void setRealm(Realm realm) { this.realm = realm; } public void setWWWAuthRealm(String wwwAuthRealm) { this.wwwAuthRealm = wwwAuthRealm; } public void setPrincipalAuthRealm(String principalAuthRealm) { this.principalAuthRealm = principalAuthRealm; } public void setExpireSeconds(long seconds) { this.expireSeconds = seconds; } public void setRefreshSeconds(long seconds) { this.refreshSeconds = seconds; } public void setRefreshExpireSeconds(long seconds) { this.refreshExpire = seconds; } }