/*
|
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 <https://www.gnu.org/licenses/>.
|
*/
|
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(HttpExchange e, String userId, String password) {
|
if (realm.isValid(userId, password)) {
|
logger.info(userId + " logged in from IP." + e.getRemoteAddress());
|
LoginResponse r = new LoginResponse();
|
String token = createToken(userId, expireSeconds);
|
r.setToken(token);
|
r.setRefreshToken(createToken(userId, refreshExpire));
|
r.setExpiresIn(expireSeconds);
|
return r;
|
} else {
|
logger.info("Invalid log in attempt for " + userId + " from IP " + e.getRemoteAddress());
|
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);
|
}
|
|
/**
|
* Den Realm dieses Authenticators setzen
|
* @param realm der Realm
|
*/
|
public void setRealm(Realm realm) {
|
this.realm = realm;
|
}
|
|
/**
|
* Pruefen, ob ein Nutzer eine Rolle hat
|
* @param userId die Kennung des Nutzers
|
* @param roleId die Rollen-ID des Nutzers
|
* @return true, wenn der Nutzer die Rolle hat, false wenn nicht
|
*/
|
public boolean hasRole(String userId, String roleId) {
|
return realm.hasRole(userId, roleId);
|
}
|
|
/**
|
* Den Eintrag fuer das 'realm'-Attribut
|
* zur Nutzung im WWW-Authenticate Header setzen
|
*
|
* @param wwwAuthRealm der Text fuer das realm-Attribut im
|
* WWW-Autehnticate-Header
|
*/
|
public void setWWWAuthRealm(String wwwAuthRealm) {
|
this.wwwAuthRealm = wwwAuthRealm;
|
}
|
|
/**
|
* Den Namen des Realms setzen, wie er fuer authentifizierte Benutzer
|
* vom Principal ausgegeben werden soll
|
*
|
* @param principalAuthRealm der Name des Realms fuer authentifizierte
|
* Benutzer
|
*/
|
public void setPrincipalAuthRealm(String principalAuthRealm) {
|
this.principalAuthRealm = principalAuthRealm;
|
}
|
|
/**
|
* Die Dauer der Gueltigkeit einer Authentifizierung in Sekunden
|
* @param seconds die Sekunden, nach denen die Authentifizierung
|
* ungueltig wird
|
*/
|
public void setExpireSeconds(long seconds) {
|
this.expireSeconds = seconds;
|
}
|
|
/**
|
* Die Dauer bis eine Authentifizierung eine Erneuerung benoetigt in Sekunden
|
* @param seconds die Sekunden, nach denen die Authentifizierung
|
* eine Erneuerung benoetigt
|
*/
|
public void setRefreshSeconds(long seconds) {
|
this.refreshSeconds = seconds;
|
}
|
|
/**
|
* Die Dauer der Gueltigkeit eines Refresh-Token in Sekunden
|
*
|
* @param seconds die Anzhal Sekunden, die ein Refresh-Token gueltig ist
|
*/
|
public void setRefreshExpireSeconds(long seconds) {
|
this.refreshExpire = seconds;
|
}
|
}
|