/*
|
http-auth - Authentication 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 <https://www.gnu.org/licenses/>.
|
*/
|
package de.uhilger.httpserver.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.httpserver.auth.session.AuthenticatedSession;
|
import de.uhilger.httpserver.auth.session.Session;
|
import de.uhilger.httpserver.auth.session.SessionManager;
|
import de.uhilger.httpserver.auth.session.Sessions;
|
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.SignatureAlgorithm;
|
import io.jsonwebtoken.security.Keys;
|
import java.security.Key;
|
import java.text.SimpleDateFormat;
|
import java.util.Date;
|
import java.util.Iterator;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.logging.Level;
|
import java.util.logging.Logger;
|
import de.uhilger.httpserver.auth.realm.Realm;
|
import de.uhilger.httpserver.auth.session.SweepThread;
|
import io.jsonwebtoken.JwtException;
|
|
/**
|
* Ein Authenticator fuer HTTP-Anfragen, der den Mechanismus JSON Web Token
|
* (JWT) verwendet.
|
*
|
* Dieser Authenticator fungiert als Ausgabeinstanz fuer JWT. Ueber die Methode
|
* <code>anmelden</code> muessen Nutzer sich Authentisieren. Die Angaben der
|
* Nutzer werden dabei gegen ein Nutzerverzeichnis auf Gueltigkeit ueberprueft.
|
* So authentifizierte Nutzer erhalten zur Bestaetigung ihrer Authentifizierung
|
* ein JWT ausgestellt.
|
*
|
* Der JWT wird als httpOnly Cookie namens
|
* <code>JWTAuthenticator.JWT_INDICATOR</code>
|
* an den Browser des Benutzers ausgegeben, der
|
* weitere HTTP-Anfragen des Nutzers daraufhin mit dem JWT versieht.
|
*
|
* HTTP-Anfragen solcher Nutzer prueft dieser Authenticator auf gueltige JWT.
|
* Anfragen mit gueltigem JWT werden dem Server als erfolgreich authentifiziert
|
* gemeldet, Anfragen ohne gueltigen JWT werden dem Server als nicht
|
* authentifiziert gemeldet.
|
*
|
* @author Ulrich Hilger
|
* @version 1, 21. Mai 2021
|
*/
|
public abstract class TokenAuthenticator extends Authenticator {
|
|
/* Der Logger fuer diesen JWTAuthenticator */
|
private static final Logger logger = Logger.getLogger(TokenAuthenticator.class.getName());
|
|
public static final String STR_SLASH = "/";
|
public static final String STR_BLANK = " ";
|
public static final String STR_AMP = "&";
|
public static final String STR_EQUAL = "=";
|
public static final String STR_SEMICOLON = ";";
|
|
/** der Name des Cookie Headers */
|
public static final String COOKIE_HEADER = "Cookie";
|
|
public static final String SET_COOKIE_HEADER = "Set-Cookie";
|
|
/** Der Name des JSON-Web-Token-Cookies */
|
public static final String JWT_INDICATOR = "JWT";
|
|
public static final String LOGIN_JWT_INDICATOR = "LOGIN-JWT";
|
|
public static final String EXPIRES = "Expires";
|
public static final String PATH = "Path";
|
public static final String HTTP_ONLY = "HttpOnly";
|
public static final String SAME_SITE = "SameSite";
|
public static final String STRICT = "Strict";
|
|
/**
|
* Ein Token ist 4 Stunden gueltig, also
|
* 60 Sekunden x 60 Minuten x 4 Stunden = 14.400 Sekunden
|
*/
|
public static final long TOKEN_EXPIRATION = 14400;
|
|
public static final int SC_UNAUTHORIZED = 401;
|
|
public static final String HEADER_DATE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss z";
|
|
/**
|
* Das Nutzerverzeichnis, gegen das die bei der Anmeldung gemachten Angaben
|
* gepueft werden sollen
|
*/
|
private Realm nutzerverzeichnis;
|
|
/** der Schluessel zur Signatur von Tokens */
|
protected final Key key;
|
|
/** die Sessions authentifizierter Nutzer */
|
private final SessionManager sessions;
|
|
/** Der Kontext dieser App */
|
//protected final String ctx;
|
|
protected Date lastSweep;
|
|
/**
|
* Ein Objekt der Klasse <code>JWTAuthenticator</code> erzeugen.
|
*/
|
public TokenAuthenticator() {
|
//this.ctx = ctx;
|
//nutzerverzeichnis = new TestRealm();
|
//paesse = new HashMap();
|
//sessions = new HashMap();
|
sessions = new Sessions();
|
key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
|
lastSweep = new Date();
|
}
|
|
/**
|
* Eine Anfrage authentifizieren.
|
*
|
* Hier wird geprueft, ob der Anfrage ein JSON Web Token beigefuegt ist. Wenn
|
* kein JWT beigefuegt ist, wird Authenticator.Failure zurueckgegeben. Wenn
|
* ein JWT beigefuegt ist, wird geprueft, ob der JWT gueltig ist. Wenn der JWT
|
* nicht gueltig ist, wird Authenticator.Failure zurueckgegeben. Wenn der JWT
|
* gueltig ist, wird Authenticator.Success zurueckgegeben.
|
*
|
* Ein JWT ist gueltig, wenn die Signatur des JWT mit dem Schluessel dieses
|
* Authenticators uebereinstimmt, wenn die Nutzerkennung des JWT
|
* uebereinstimmt mit der Nutzerkennung der Session fuer diesen JWT, wenn
|
* die Gueltigkeitsdauer des tokens noch nicht abgelaufen ist und wenn
|
* die Session noch nicht abgelaufen ist.
|
*
|
* Tokens bekommen eine feste Gueltigkeitsdauer. Es kann daher vorkommen,
|
* dass eine Session laenger gueltig ist, als der Token. In diesem Fall
|
* erscheint fuer diesen Benutzer eine erneute Aufforderung zur Anmeldung.
|
* Mit erfolgreicher neuerlicher Anmeldung wird ein neuer Token ausgegeben
|
* und in der Session gefuehrt.
|
*
|
* @param exchange das Objekt mit den HTTP Request und Reponse Informationen
|
* @return Authenticator.Success, wenn de Authentifizierung erfolgreich ist,
|
* Authenticator.Failure wenn nicht
|
*/
|
@Override
|
public Result authenticate(HttpExchange exchange) {
|
logger.info(exchange.getRequestURI().toString());
|
housekeeping();
|
String jwt = cookieLesen(exchange, JWT_INDICATOR);
|
if (jwt != null) {
|
//String jws = parts[1];
|
String userId = getUserId(jwt);
|
if (userId != null) {
|
try {
|
Claims body = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt).getBody();
|
String jwtUserId = body.getSubject();
|
if(jwtUserId.equals(userId)) {
|
Date now = new Date();
|
//Object sessionObj = sessions.get(jwt);
|
Session s = sessions.get(jwt);
|
if(s instanceof AuthenticatedSession) {
|
AuthenticatedSession session = (AuthenticatedSession) s;
|
if(session.getExp().after(now)) {
|
// Session erneuern
|
Date exp = Date.from(now.toInstant().plusSeconds(Session.EXPIRATION));
|
session = new AuthenticatedSession();
|
session.setId(jwt);
|
session.setExp(exp);
|
session.setUserId(userId);
|
sessions.remove(jwt);
|
//sessions.remove(jwt);
|
sessions.add(session);
|
//sessions.put(jwt, session);
|
// erfolgreiche Authentifizierung melden
|
try {
|
HttpPrincipal pp = new HttpPrincipal(userId, nutzerverzeichnis.getName());
|
Result result = new Authenticator.Success(pp);
|
return result;
|
} catch (Exception ex) {
|
Logger.getLogger(TokenAuthenticator.class.getName()).log(Level.SEVERE, null, ex);
|
return new Authenticator.Failure(SC_UNAUTHORIZED);
|
}
|
} else {
|
// Session abgelaufen
|
sessions.remove(jwt);
|
//sessions.remove(jwt);
|
return login(exchange);
|
}
|
} else {
|
// Session nicht gefunden
|
return login(exchange);
|
}
|
} else {
|
// JWT-Inhalt entspricht nicht der Benutzerkennung der Session
|
return login(exchange);
|
}
|
}
|
catch (JwtException ex) {
|
// we *cannot* use the JWT as intended by its creator
|
// z.B. Expiration Date ueberschritten
|
sessions.remove(jwt);
|
return login(exchange);
|
}
|
} else {
|
// JWT ist nicht in der Liste der zur Zeit aktiven JWTs
|
return login(exchange);
|
}
|
} else {
|
// kein JWT vorhanden
|
return login(exchange);
|
}
|
}
|
|
public void setRealm(Realm realm) {
|
this.nutzerverzeichnis = realm;
|
}
|
|
/**
|
* Den Client zur Authentisierung auffordern
|
*
|
* @param exchange das Objekt mit Infos zu Anfrage und Antwort
|
* @return das Ergebnis dieser Authenthisierung,
|
* typischerweise Authenticator.Retry
|
*/
|
protected abstract Authenticator.Result login(HttpExchange exchange);
|
|
protected void housekeeping() {
|
Date sweepTime = Date.from(lastSweep.toInstant().plusSeconds(TOKEN_EXPIRATION));
|
Date now = new Date();
|
if(now.after(sweepTime)) {
|
SweepThread sweeper = new SweepThread(sessions);
|
sweeper.start();
|
}
|
}
|
|
/*
|
public String cookieBilden(String name, String wert, Date exp, String path) {
|
StringBuilder sb = new StringBuilder();
|
sb.append(name);
|
sb.append(STR_EQUAL);
|
sb.append(wert);
|
sb.append(STR_SEMICOLON);
|
sb.append(STR_BLANK);
|
sb.append(EXPIRES);
|
sb.append(STR_EQUAL);
|
SimpleDateFormat fmt = new SimpleDateFormat(HEADER_DATE_PATTERN, Locale.US);
|
sb.append(fmt.format(exp));
|
sb.append(STR_SEMICOLON);
|
sb.append(PATH);
|
sb.append(STR_EQUAL);
|
sb.append(path);
|
sb.append(STR_SEMICOLON);
|
sb.append(STR_BLANK);
|
sb.append(HTTP_ONLY);
|
return sb.toString();
|
}
|
*/
|
|
|
public String cookieBilden(String name, String token, Date exp) {
|
StringBuilder sb = new StringBuilder();
|
// JWT=jjhdksdghfds...
|
sb.append(name);
|
sb.append(STR_EQUAL);
|
sb.append(token);
|
// ; Expires=[Zeitpunkt]
|
sb.append(STR_SEMICOLON);
|
sb.append(STR_BLANK);
|
sb.append(EXPIRES);
|
sb.append(STR_EQUAL);
|
SimpleDateFormat fmt = new SimpleDateFormat(HEADER_DATE_PATTERN, Locale.US);
|
sb.append(fmt.format(exp));
|
// ; Path=/app-context
|
sb.append(STR_SEMICOLON);
|
sb.append(STR_BLANK);
|
sb.append(PATH);
|
sb.append(STR_EQUAL);
|
//sb.append(path);
|
sb.append(STR_SLASH);
|
// ; SameSite=Strict
|
sb.append(STR_SEMICOLON);
|
sb.append(STR_BLANK);
|
sb.append(SAME_SITE);
|
sb.append(STR_EQUAL);
|
sb.append(STRICT);
|
// ; HttpOnly
|
sb.append(STR_SEMICOLON);
|
sb.append(STR_BLANK);
|
sb.append(HTTP_ONLY);
|
return sb.toString();
|
}
|
|
|
/**
|
* Wenn der JSON Web Token als Cookie an den Client gegeben wird, liefert die
|
* Anfrage vom Client den JWT im Header als Cookie wie folgt:
|
*
|
* <pre>
|
* Cookie: JWT=inhalt; anderer_cookie=anderer-inhalt
|
* </pre>
|
*
|
* @param exchange das Objekt mit Angaben zu HTTP Request und Response
|
* @return JSON Web Token aus dem Cookie Header oder null, wenn kein JWT
|
* vorhanden ist
|
*/
|
public String cookieLesen(HttpExchange exchange, String key) {
|
String wert = null;
|
Headers headers = exchange.getRequestHeaders();
|
List<String> cookies = headers.get(COOKIE_HEADER);
|
if(cookies != null && cookies.size() > 0) {
|
Iterator cookieIterator = cookies.iterator();
|
while (cookieIterator.hasNext() && wert == null) {
|
Object cookieObj = cookieIterator.next();
|
if (cookieObj instanceof String) {
|
String cookieStr = (String) cookieObj;
|
String[] teile = cookieStr.split("; ");
|
for(String cookie : teile) {
|
String[] cookieTeil = cookie.split(STR_EQUAL);
|
if(cookieTeil[0].equals(key)) {
|
return cookieTeil[1];
|
}
|
}
|
}
|
}
|
}
|
return wert;
|
}
|
|
/**
|
* Die Nutzer-ID zu einem Token bestimmen.
|
*
|
* Pruefen, ob ein Token in der Liste der zur Zeit gueltigen Token
|
* enthalten ist, also eine gueltige Session bezeichnet. Wenn ja, wird
|
* zudem geprueft, ob es sich um eine AuthenticatedSession handelt. Wenn
|
* ja, wird die Nutzer-ID dieser Session zurueckgegeben.
|
*
|
* Diese Methode prueft nicht, wie die Nutzer-ID des tokens lautet. Dies
|
* wird in der Methode <code>authenticate</code> gemacht.
|
*
|
* @param token der Token, fuer den die Nutzer-ID bestimmt werden soll
|
* @return Nutzer-ID der gueltigen Session oder null, wenn der Token keiner
|
* gueltigen Session entspricht
|
*/
|
private String getUserId(String token) {
|
Object sessionObj = sessions.get(token);
|
if (sessionObj instanceof AuthenticatedSession) {
|
AuthenticatedSession session = (AuthenticatedSession) sessionObj;
|
return session.getUserId();
|
} else {
|
return null;
|
}
|
}
|
|
public String anmelden(String userId, String password) {
|
if (nutzerverzeichnis.isValid(userId, password)) {
|
return jwtErzeugen(userId);
|
} else {
|
return null;
|
}
|
}
|
|
private String jwtErzeugen(String userId) {
|
Date now = new Date();
|
Date exp = Date.from(now.toInstant().plusSeconds(TokenAuthenticator.TOKEN_EXPIRATION));
|
String jws = Jwts
|
.builder()
|
.setSubject(userId)
|
.setIssuedAt(now)
|
.setExpiration(exp)
|
.signWith(key)
|
.compact();
|
AuthenticatedSession session = new AuthenticatedSession();
|
session.setId(jws);
|
exp = Date.from(now.toInstant().plusSeconds(Session.EXPIRATION));
|
session.setExp(exp);
|
session.setUserId(userId);
|
sessions.remove(jws);
|
//sessions.remove(jws);
|
sessions.add(session);
|
//sessions.put(jws, session);
|
return jws;
|
}
|
|
/**
|
* Die Session mit einem bestimmten token ausloggen
|
*
|
* @param token der JWT, der ausgeloggt werden soll
|
*/
|
public void abmelden(String token) {
|
//sessions.remove(token);
|
sessions.remove(token);
|
}
|
}
|