Dieser Artikel ist die Demoanwendung, die im vorherigen Beitrag "Implementieren einer einfachen Rest-API mit Spring Security mit Spring Boot 2.0.1" verwendet wurde. Ist ein Artikel, der die Änderungen erklärt, wenn JWT (Json Web Token) kompatibel gemacht wird. Es gibt viele detaillierte Artikel zu den JWT-Spezifikationen, daher werde ich sie hier nicht behandeln.
Den Quellcode finden Sie unter rubytomato / demo-security-jwt-spring2.
Umgebung
Referenz
Diese Demo-Anwendung authentifiziert sich mit Ihrer E-Mail-Adresse und Ihrem Passwort. Insbesondere wird eine POST-Anforderung für eine E-Mail-Adresse und ein Kennwort an die Anmelde-API gesendet. Wenn eine Authentifizierung möglich ist, werden ein HTTP-Status 200 und ein JWT-Token zurückgegeben. Die anschließende Authentifizierung und Autorisierung erfolgt mit dem im Anforderungsheader festgelegten JWT-Token.
Ich habe Java-JWT aus den Bibliotheken ausgewählt, die mehrere JWTs verarbeiten.
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.3.0</version>
</dependency>
Einige Token-Payload- (Anspruchs-) Kennungen sind registrierte Anspruchsnamen. Beide werden als optional behandelt. Ob sie festgelegt werden sollen oder nicht, hängt also von der zu verwendenden Anwendung ab.
id | name | description |
---|---|---|
jti | JWT ID | JWT eindeutige Kennung |
aud | Audience | JWT-Benutzer |
iss | Issuer | Herausgeber von JWT |
sub | Subject | Hauptteil von JWT.Eindeutiger oder global eindeutiger Wert im Kontext des JWT-Emittenten |
iat | Issued At | JWT-Ausstellungszeit |
nbf | Not Before | Startzeit des JWT-Gültigkeitszeitraums.Vor dieser Zeit nicht verfügbar |
exp | Expiration Time | Endzeit der JWT-Gültigkeitsdauer.Nach dieser Zeit nicht mehr verfügbar |
private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;
public void build() {
String secretKey = "secret";
Date issuedAt = new Date();
Date notBefore = new Date(issuedAt.getTime());
Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
String token = JWT.create()
// registered claims
//.withJWTId("jwtId") //"jti" : JWT ID
//.withAudience("audience") //"aud" : Audience
//.withIssuer("issuer") //"iss" : Issuer
.withSubject("test") //"sub" : Subject
.withIssuedAt(issuedAt) //"iat" : Issued At
.withNotBefore(notBefore) //"nbf" : Not Before
.withExpiresAt(expiresAt) //"exp" : Expiration Time
//private claims
.withClaim("X-AUTHORITIES", "aaa")
.withClaim("X-USERNAME", "bbb")
.sign(algorithm);
System.out.println("generate token : " + token);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
Die durch diese Einstellung generierten Token lauten wie folgt.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY
Sie können Token auf der Site JSON Web Tokens --jwt.io dekodieren.
public void verify() {
String secretKey = "secret";
String token = "";
try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT jwt = verifier.verify(token);
// registered claims
String subject = jwt.getSubject();
Date issuedAt = jwt.getIssuedAt();
Date notBefore = jwt.getNotBefore();
Date expiresAt = jwt.getExpiresAt();
System.out.println("subject : [" + subject + "] issuedAt : [" + issuedAt.toString() + "] notBefore : [" + notBefore.toString() + "] expiresAt : [" + expiresAt.toString() + "]");
// subject : [test] issuedAt : [Thu Apr 12 13:19:00 JST 2018] notBefore : [Thu Apr 12 13:19:00 JST 2018] expiresAt : [Thu Apr 12 13:29:00 JST 2018]
// private claims
String authorities = jwt.getClaim("X-AUTHORITIES").asString();
String username = jwt.getClaim("X-USERNAME").asString();
System.out.println("private claim X-AUTHORITIES : [" + authorities + "] X-USERNAME : [" + username + "]");
// private claim X-AUTHORITIES : [aaa] X-USERNAME : [bbb]
} catch (UnsupportedEncodingException | JWTVerificationException e) {
e.printStackTrace();
}
}
Die Ausnahmen, die nicht erschöpfend sind, aber wahrscheinlich auftreten (anfällig für das Auftreten), sind: Beide Ausnahmen sind Unterklassen von JWTVerificationException.
exception | description |
---|---|
SignatureVerificationException | Wenn der geheime Schlüssel anders ist usw. |
AlgorithmMismatchException | Wenn der Signaturalgorithmus anders ist usw. |
JWTDecodeException | Zum Beispiel, wenn das Token manipuliert wurde |
TokenExpiredException | Wenn das Token abgelaufen ist |
InvalidClaimException | Bevor Sie mit der Verwendung von Token usw. beginnen. |
Zeigt die geänderten Teile für JWT an.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserRepository userRepository;
// ★1
@Value("${security.secret-key:secret}")
private String secretKey = "secret";
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
http
// AUTHORIZE
.authorizeRequests()
.mvcMatchers("/hello/**")
.permitAll()
.mvcMatchers("/user/**")
.hasRole("USER")
.mvcMatchers("/admin/**")
.hasRole("ADMIN")
.anyRequest()
.authenticated()
.and()
// EXCEPTION
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler())
.and()
// LOGIN
.formLogin()
.loginProcessingUrl("/login").permitAll()
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(authenticationSuccessHandler())
.failureHandler(authenticationFailureHandler())
.and()
// ★2 LOGOUT
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler())
.and()
// ★3 CSRF
.csrf()
.disable()
// ★4 AUTHORIZE
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
// ★5 SESSION
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
;
// @formatter:on
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.eraseCredentials(true)
.userDetailsService(simpleUserDetailsService())
.passwordEncoder(passwordEncoder());
}
@Bean("simpleUserDetailsService")
UserDetailsService simpleUserDetailsService() {
return new SimpleUserDetailsService(userRepository);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
GenericFilterBean tokenFilter() {
return new SimpleTokenFilter(userRepository, secretKey);
}
AuthenticationEntryPoint authenticationEntryPoint() {
return new SimpleAuthenticationEntryPoint();
}
AccessDeniedHandler accessDeniedHandler() {
return new SimpleAccessDeniedHandler();
}
AuthenticationSuccessHandler authenticationSuccessHandler() {
return new SimpleAuthenticationSuccessHandler(secretKey);
}
AuthenticationFailureHandler authenticationFailureHandler() {
return new SimpleAuthenticationFailureHandler();
}
LogoutSuccessHandler logoutSuccessHandler() {
return new HttpStatusReturningLogoutSuccessHandler();
}
}
Die Einstellungen für die Authentifizierung wurden nicht geändert.
configuration
.formLogin()
.loginProcessingUrl("/login").permitAll()
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(authenticationSuccessHandler())
.failureHandler(authenticationFailureHandler())
successHandler()
Vor der Änderung war es ein Handler, der nur den HTTP-Status 200 zurückgegeben hat. Er wurde jedoch geändert, um aus den Anmeldeinformationen ein Token zu generieren und es im Antwortheader festzulegen.
AuthenticationSuccessHandler authenticationSuccessHandler() {
return new SimpleAuthenticationSuccessHandler(secretKey);
}
Da die in der Token-Nutzlast (Anspruch) festgelegten Informationen von jedem dekodiert werden können, wählen Sie die Informationen aus, die nach außen offengelegt werden können. In diesem Beispiel wird die Benutzer-ID im Betreff festgelegt, und zum Zeitpunkt der Autorisierung wird die Benutzer-ID vom Token erfasst und der Benutzer durchsucht.
String token = JWT.create()
.withIssuedAt(issuedAt) //JWT-Ausstellungszeit
.withNotBefore(notBefore) //Startzeit für JWT-Ablauf
.withExpiresAt(expiresAt) //Endzeit des JWT-Ablaufdatums
.withSubject(loginUser.getUser().getId().toString()) //Eindeutiger oder global eindeutiger Wert im Kontext der JWT-Entität, des JWT-Emittenten
.sign(this.algorithm);
Die generateToken-Methode generiert ein Token aus den Anmeldeinformationen, und die setToken-Methode legt das Token im Authorization-Header fest.
SimpleAuthenticationSuccessHandler
@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
final private Algorithm algorithm;
public SimpleAuthenticationSuccessHandler(String secretKey) {
Objects.requireNonNull(secretKey, "secret key must be not null");
try {
this.algorithm = Algorithm.HMAC256(secretKey);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication auth) throws IOException, ServletException {
if (response.isCommitted()) {
log.info("Response has already been committed.");
return;
}
setToken(response, generateToken(auth));
response.setStatus(HttpStatus.OK.value());
clearAuthenticationAttributes(request);
}
private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;
private String generateToken(Authentication auth) {
SimpleLoginUser loginUser = (SimpleLoginUser) auth.getPrincipal();
Date issuedAt = new Date();
Date notBefore = new Date(issuedAt.getTime());
Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
String token = JWT.create()
.withIssuedAt(issuedAt)
.withNotBefore(notBefore)
.withExpiresAt(expiresAt)
.withSubject(loginUser.getUser().getId().toString())
.sign(this.algorithm);
log.debug("generate token : {}", token);
return token;
}
private void setToken(HttpServletResponse response, String token) {
response.setHeader("Authorization", String.format("Bearer %s", token));
}
/**
* Removes temporary authentication-related data which may have been stored in the
* session during the authentication process.
*/
private void clearAuthenticationAttributes(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}
Wir haben einen neuen Filter implementiert, der Token validiert und autorisiert. Dieser Filter soll vor dem Filter ausgeführt werden, der sich durch Benutzername / Passwort (UsernamePasswordAuthenticationFilter) authentifiziert.
configuration
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
SimpleTokenFilter
GenericFilterBean tokenFilter() {
return new SimpleTokenFilter(userRepository, secretKey);
}
Beziehen und validieren Sie das Token aus dem Anforderungsheader "Authorization". Bei der Überprüfung wird nicht nur geprüft, ob das Token manipuliert wurde, sondern auch das festgelegte Ablaufdatum. Wenn das Token normal ist, wird die Benutzer-ID zum Durchsuchen der Benutzerinformationen von der Nutzlast abgerufen und der Benutzer wird durchsucht.
SimpleTokenFilter
@Slf4j
public class SimpleTokenFilter extends GenericFilterBean {
final private UserRepository userRepository;
final private Algorithm algorithm;
public SimpleTokenFilter(UserRepository userRepository, String secretKey) {
Objects.requireNonNull(secretKey, "secret key must be not null");
this.userRepository = userRepository;
try {
this.algorithm = Algorithm.HMAC256(secretKey);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String token = resolveToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
try {
authentication(verifyToken(token));
} catch (JWTVerificationException e) {
log.error("verify token error", e);
SecurityContextHolder.clearContext();
((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
}
filterChain.doFilter(request, response);
}
private String resolveToken(ServletRequest request) {
String token = ((HttpServletRequest) request).getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return null;
}
return token.substring(7);
}
private DecodedJWT verifyToken(String token) {
JWTVerifier verifier = JWT.require(algorithm).build();
return verifier.verify(token);
}
private void authentication(DecodedJWT jwt) {
Long userId = Long.valueOf(jwt.getSubject());
userRepository.findById(userId).ifPresent(user -> {
SimpleLoginUser simpleLoginUser = new SimpleLoginUser(user);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(simpleLoginUser, null, simpleLoginUser.getAuthorities()));
});
}
}
Beim Abmelden werden nur die URL und der Handler festgelegt. Ich wollte das zum Zeitpunkt der Abmeldung ausgestellte Token ungültig machen, aber soweit ich es überprüft habe, scheint es, dass das Token nicht ungültig gemacht werden kann, sodass der Handler nur den HTTP-Status zurückgibt.
configuration
.logout()
.logoutUrl("/logout")
//.invalidateHttpSession(true)
//.deleteCookies("JSESSIONID")
.logoutSuccessHandler(logoutSuccessHandler())
Wenn es um die Ungültigmachung auf der Serverseite geht, scheint es eine Methode zu geben, das Token des abgemeldeten Benutzers für einen bestimmten Zeitraum (bis zum Ablaufdatum des Tokens) zu halten und nicht zu autorisieren, wenn sie während des Autorisierungsprozesses übereinstimmen. ..
CSRF
Ich benutze kein CSRF, also habe ich es deaktiviert.
configuration
.csrf()
.disable()
//.ignoringAntMatchers("/login")
//.csrfTokenRepository(new CookieCsrfTokenRepository())
SessionManagement
Ich habe es auf zustandslos gesetzt, weil es keine Sitzungen verwaltet. Mit dieser Einstellung verwendet Spring Security kein HttpSession.
configuration
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
Durch den Wechsel zu JWT sind weder Sitzungscookies noch CSRF-Token erforderlich. Legen Sie stattdessen das nach der Authentifizierung empfangene JWT-Token im Anforderungsheader fest.
** Für ein gültiges Konto **
Wenn die Authentifizierung erfolgreich ist, wird das Token im Autorisierungsheader festgelegt, wie im folgenden Beispiel gezeigt.
> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=iWKw06pvj"
HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmJmIjoxNTIzNTExNjY5LCJleHAiOjE1MjM1MTIyNjksImlhdCI6MTUyMzUxMTY2OX0.E6HZShowNPUvNj84dYRHMyZROxIvYjsEP7e29
_QLXic
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Thu, 12 Apr 2018 05:41:09 GMT
** Für ungültiges Konto **
Falsche E-Mail-Adresse, falsches Passwort usw.
> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge"
HTTP/1.1 401
** Wenn Sie ein gültiges Token angeben **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"
HTTP/1.1 200
** Wenn kein Token angegeben ist **
> curl -i "http://localhost:9000/app/memo/1"
HTTP/1.1 401
** Wenn der Token manipuliert wird **
> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"
HTTP/1.1 401
** Wenn der Token abgelaufen ist **
> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"
HTTP/1.1 401
** Für Benutzer mit der Rolle USER **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"
HTTP/1.1 200
** Für Benutzer mit der ADMIN-Rolle **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"
HTTP/1.1 200
** Für Benutzer, die nicht die ADMIN-Rolle haben **
Wenn das Token erfolgreich ist, der Benutzer jedoch nicht über die erforderliche Rolle verfügt, tritt ein Fehler auf.
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"
HTTP/1.1 403