
使用JWT進行身份驗證
??應(yīng)用程序的Github存儲庫:https://github.com/OmarElGabry/microservices-spring-boot
身份驗證工作流程
身份驗證流程很簡單:
- 用戶發(fā)送請求以獲取傳遞其憑據(jù)的令牌(token)。
- 服務(wù)器驗證憑據(jù)并發(fā)回令牌。
- 對于每個請求,用戶必須提供令牌,服務(wù)器將驗證該令牌。
我們將引入另一項稱為“auth service”的服務(wù),用于驗證用戶憑據(jù)和頒發(fā)令牌。
驗證令牌怎么樣?好吧,它可以在auth service本身中實現(xiàn),并且網(wǎng)關(guān)必須在允許請求轉(zhuǎn)到任何服務(wù)之前調(diào)用auth service來驗證令牌。
相反,我們可以在網(wǎng)關(guān)級別驗證令牌,并讓auth service驗證用戶憑據(jù),并發(fā)出令牌。這就是我們要在這里做的事情。
在這兩種方式中,我們都會阻止請求,除非它經(jīng)過身份驗證(生成令牌的請求除外)。
基于JSON的令牌(JWT)
令牌是一個編碼字符串,由我們的應(yīng)用程序生成(經(jīng)過身份驗證后),并由用戶沿每個請求發(fā)送,以允許訪問我們的應(yīng)用程序公開的資源。
基于JSON的令牌(JWT)是一種基于JSON的開放標(biāo)準(zhǔn),用于創(chuàng)建訪問令牌。它由三部分組成; 標(biāo)頭(header),有效負(fù)載(payload)和簽名(signature)。
標(biāo)頭包含散列算法
{type: “JWT”, hash: “HS256”}
有效負(fù)載包含屬性(用戶名,電子郵件等)及其值。
{username: "Omar", email: "omar@example.com", admin: true }
簽名是哈希: Header + “.” + Payload + Secret key
網(wǎng)關(guān)
在網(wǎng)關(guān)中,我們需要做兩件事:(1)針對每個請求驗證令牌,以及(2)阻止對我們的服務(wù)的所有未經(jīng)身份驗證的請求。
在pom.xml添加spring安全性和JWT依賴項中。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
在application.properties添加路徑AUTH服務(wù)(我們將在稍后創(chuàng)建它)。
# Map path to auth service
zuul.routes.auth-service.path=/auth/**
zuul.routes.auth-service.service-id=AUTH-SERVICE
# By default, all requests to gallery service for example will start with: "/gallery/"
# What will be sent to the gallery service is what comes after the path defined,
# So, if request is "/gallery/view/1", gallery service will get "/view/1".
# In case of auth, we need to pass the "/auth/" in the path to auth service. So, set strip-prefix to false
zuul.routes.auth-service.strip-prefix=false
# Exclude authorization from sensitive headers
zuul.routes.auth-service.sensitive-headers=Cookie,Set-Cookie
要定義我們的安全性配置,創(chuàng)建一個類,并使用注釋@EnableWebSecurity,并使用extends WebSecurityConfigurerAdapter類來覆蓋并提供我們自己的自定義安全性配置。
package com.eureka.zuul.security;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.eureka.zuul.security.JwtConfig;
@EnableWebSecurity // Enable security config. This annotation denotes config for spring security.
public class SecurityTokenConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtConfig jwtConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// make sure we use stateless session; session won't be used to store user's state.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// handle an authorized attempts
.exceptionHandling().authenticationEntryPoint((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
// Add a filter to validate the tokens with every request
.addFilterAfter(new JwtTokenAuthenticationFilter(jwtConfig), UsernamePasswordAuthenticationFilter.class)
// authorization requests config
.authorizeRequests()
// allow all who are accessing "auth" service
.antMatchers(HttpMethod.POST, jwtConfig.getUri()).permitAll()
// must be an admin if trying to access admin area (authentication is also required here)
.antMatchers("/gallery" + "/admin/**").hasRole("ADMIN")
// Any other request must be authenticated
.anyRequest().authenticated();
}
@Bean
public JwtConfig jwtConfig() {
return new JwtConfig();
}
}
Spring具有將在請求的生命周期(過濾器鏈)內(nèi)執(zhí)行的過濾器。要啟用和使用這些過濾器,我們需要擴展任何這些過濾器的類。
默認(rèn)情況下,spring會嘗試確定何時應(yīng)該執(zhí)行過濾器。否則,我們還可以定義何時應(yīng)該執(zhí)行(在另一個過濾器之后或之前)。
這JwtConfig只是一個包含配置變量的類。
public class JwtConfig {
@Value("${security.jwt.uri:/auth/**}")
private String Uri;
@Value("${security.jwt.header:Authorization}")
private String header;
@Value("${security.jwt.prefix:Bearer }")
private String prefix;
@Value("${security.jwt.expiration:#{24*60*60}}")
private int expiration;
@Value("${security.jwt.secret:JwtSecretKey}")
private String secret;
// getters ...
}
最后一步是實現(xiàn)驗證令牌的過濾器。我們正在使用OncePerRequestFilter。它保證每個請求單次執(zhí)行(因為您可以在過濾器鏈上多次使用過濾器)。
package com.eureka.zuul.security;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import com.eureka.zuul.security.JwtConfig;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
private final JwtConfig jwtConfig;
public JwtTokenAuthenticationFilter(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1. get the authentication header. Tokens are supposed to be passed in the authentication header
String header = request.getHeader(jwtConfig.getHeader());
// 2. validate the header and check the prefix
if(header == null || !header.startsWith(jwtConfig.getPrefix())) {
chain.doFilter(request, response); // If not valid, go to the next filter.
return;
}
// If there is no token provided and hence the user won't be authenticated.
// It's Ok. Maybe the user accessing a public path or asking for a token.
// All secured paths that needs a token are already defined and secured in config class.
// And If user tried to access without access token, then he won't be authenticated and an exception will be thrown.
// 3. Get the token
String token = header.replace(jwtConfig.getPrefix(), "");
try { // exceptions might be thrown in creating the claims if for example the token is expired
// 4. Validate the token
Claims claims = Jwts.parser()
.setSigningKey(jwtConfig.getSecret().getBytes())
.parseClaimsJws(token)
.getBody();
String username = claims.getSubject();
if(username != null) {
@SuppressWarnings("unchecked")
List<String> authorities = (List<String>) claims.get("authorities");
// 5. Create auth object
// UsernamePasswordAuthenticationToken: A built-in object, used by spring to represent the current authenticated / being authenticated user.
// It needs a list of authorities, which has type of GrantedAuthority interface, where SimpleGrantedAuthority is an implementation of that interface
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
username, null, authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
// 6. Authenticate the user
// Now, user is authenticated
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
// In case of failure. Make sure it's clear; so guarantee user won't be authenticated
SecurityContextHolder.clearContext();
}
// go to the next filter in the filter chain
chain.doFilter(request, response);
}
}
驗證服務(wù)(Auth Service)
在Auth Service中,我們需要(1)驗證用戶憑證,如果有效,(2)生成令牌,否則拋出異常。
在pom.xml添加以下依賴項:Web,Eureka Client,Spring Security和JWT。
....
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
....
在里面 application.properties
spring.application.name=auth-service
server.port=9100
eureka.client.service-url.default-zone=http://localhost:8761/eureka
正如我們在Gateway中進行安全配置所做的那樣,創(chuàng)建一個帶有注釋@EnableWebSecurity和擴展的類WebSecurityConfigurerAdapter
package com.eureka.auth.security;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.eureka.auth.security.JwtConfig;
@EnableWebSecurity // Enable security config. This annotation denotes config for spring security.
public class SecurityCredentialsConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtConfig jwtConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// make sure we use stateless session; session won't be used to store user's state.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// handle an authorized attempts
.exceptionHandling().authenticationEntryPoint((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
// Add a filter to validate user credentials and add token in the response header
// What's the authenticationManager()?
// An object provided by WebSecurityConfigurerAdapter, used to authenticate the user passing user's credentials
// The filter needs this auth manager to authenticate the user.
.addFilter(new JwtUsernameAndPasswordAuthenticationFilter(authenticationManager(), jwtConfig))
.authorizeRequests()
// allow all POST requests
.antMatchers(HttpMethod.POST, jwtConfig.getUri()).permitAll()
// any other requests must be authenticated
.anyRequest().authenticated();
}
// Spring has UserDetailsService interface, which can be overriden to provide our implementation for fetching user from database (or any other source).
// The UserDetailsService object is used by the auth manager to load the user from database.
// In addition, we need to define the password encoder also. So, auth manager can compare and verify passwords.
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public JwtConfig jwtConfig() {
return new JwtConfig();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
正如您在上面的代碼中看到的,我們需要實現(xiàn)UserDetailsService接口。
該類充當(dāng)用戶的提供者; 意味著它從數(shù)據(jù)庫(或任何數(shù)據(jù)源)加載用戶。它不進行身份驗證。它只是加載用戶的用戶名。
package com.eureka.auth.security;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service // It has to be annotated with @Service.
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private BCryptPasswordEncoder encoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// hard coding the users. All passwords must be encoded.
final List<AppUser> users = Arrays.asList(
new AppUser(1, "omar", encoder.encode("12345"), "USER"),
new AppUser(2, "admin", encoder.encode("12345"), "ADMIN")
);
for(AppUser appUser: users) {
if(appUser.getUsername().equals(username)) {
// Remember that Spring needs roles to be in this format: "ROLE_" + userRole (i.e. "ROLE_ADMIN")
// So, we need to set it to that format, so we can verify and compare roles (i.e. hasRole("ADMIN")).
List<GrantedAuthority> grantedAuthorities = AuthorityUtils
.commaSeparatedStringToAuthorityList("ROLE_" + appUser.getRole());
// The "User" class is provided by Spring and represents a model class for user to be returned by UserDetailsService
// And used by auth manager to verify and check user authentication.
return new User(appUser.getUsername(), appUser.getPassword(), grantedAuthorities);
}
}
// If user not found. Throw this exception.
throw new UsernameNotFoundException("Username: " + username + " not found");
}
// A (temporary) class represent the user saved in the database.
private static class AppUser {
private Integer id;
private String username, password;
private String role;
public AppUser(Integer id, String username, String password, String role) {
this.id = id;
this.username = username;
this.password = password;
this.role = role;
}
// getters and setters ....
}
}
這是最后一步; 過濾器。
我們正在使用JwtUsernameAndPasswordAuthenticationFilter。它用于驗證用戶憑據(jù)并生成令牌。必須在POST請求中發(fā)送用戶名和密碼。
package com.eureka.auth.security;
import java.io.IOException;
import java.sql.Date;
import java.util.Collections;
import java.util.stream.Collectors;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.eureka.auth.security.JwtConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
public class JwtUsernameAndPasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
// We use auth manager to validate the user credentials
private AuthenticationManager authManager;
private final JwtConfig jwtConfig;
public JwtUsernameAndPasswordAuthenticationFilter(AuthenticationManager authManager, JwtConfig jwtConfig) {
this.authManager = authManager;
this.jwtConfig = jwtConfig;
// By default, UsernamePasswordAuthenticationFilter listens to "/login" path.
// In our case, we use "/auth". So, we need to override the defaults.
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(jwtConfig.getUri(), "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
// 1. Get credentials from request
UserCredentials creds = new ObjectMapper().readValue(request.getInputStream(), UserCredentials.class);
// 2. Create auth object (contains credentials) which will be used by auth manager
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
creds.getUsername(), creds.getPassword(), Collections.emptyList());
// 3. Authentication manager authenticate the user, and use UserDetialsServiceImpl::loadUserByUsername() method to load the user.
return authManager.authenticate(authToken);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// Upon successful authentication, generate a token.
// The 'auth' passed to successfulAuthentication() is the current authenticated user.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication auth) throws IOException, ServletException {
Long now = System.currentTimeMillis();
String token = Jwts.builder()
.setSubject(auth.getName())
// Convert to list of strings.
// This is important because it affects the way we get them back in the Gateway.
.claim("authorities", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + jwtConfig.getExpiration() * 1000)) // in milliseconds
.signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret().getBytes())
.compact();
// Add token to header
response.addHeader(jwtConfig.getHeader(), jwtConfig.getPrefix() + token);
}
// A (temporary) class just to represent the user credentials
private static class UserCredentials {
private String username, password;
// getters and setters ...
}
}
共同事務(wù)
如果您有多個服務(wù)使用的公共配置變量,枚舉類或邏輯,就像我們擁有的那樣JwtConfig。我們將其放在一個單獨的服務(wù)中,而不是復(fù)制代碼,該服務(wù)可以包含在其他服務(wù)中并作為依賴項使用。
為此,只需創(chuàng)建一個新項目(服務(wù)),將其命名為“common”,然后按照與圖像服務(wù)相同的步驟操作。所以,在pom.xml文件中
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
在里面 application.properties
spring.application.name=common-service
server.port=9200
eureka.client.service-url.default-zone=http://localhost:8761/eureka
在spring boot主應(yīng)用程序類中
package com.eureka.common;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
@SpringBootApplication
@EnableEurekaClient
public class SpringEurekaCommonApp {
public static void main(String[] args) {
SpringApplication.run(SpringEurekaCommonApp.class, args);
}
}
然后,復(fù)制JwtConfig我們之前在Gateway中創(chuàng)建的公共服務(wù)類。
package com.eureka.common.security;
import org.springframework.beans.factory.annotation.Value;
public class JwtConfig {
// ...
}
現(xiàn)在,為了能夠JwtConfig從其他服務(wù)(如auth和gateway)調(diào)用類,我們只需要將公共服務(wù)添加pom.xml為依賴項。
<dependency>
<groupId>com.eureka.common</groupId>
<artifactId>spring-eureka-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
在我們的身份驗證和網(wǎng)關(guān)服務(wù)中......
// change these lines of code
import com.eureka.zuul.security.JwtConfig;
import com.eureka.auth.security.JwtConfig;
// to reference the class in common service instead
import com.eureka.common.security.JwtConfig;
測試我們的微服務(wù)
現(xiàn)在我們插入了身份驗證邏輯,我們可以無縫地驗證憑據(jù),發(fā)放令牌和驗證用戶身份。
所以,運行我們的Eureka服務(wù)器。然后,運行其他服務(wù):image,gallery,common,auth,最后是網(wǎng)關(guān)。
首先,讓我們嘗試在localhost:8762/gallery沒有令牌的情況下訪問圖庫服務(wù)。你應(yīng)該得到Unauthorized錯誤。
{
"timestamp": "...",
"status": 401,
"error": "Unauthorized",
"message": "No message available",
"path": "/gallery/"
}
要獲取令牌,請將用戶憑據(jù)發(fā)送給localhost:8762/auth(我們在UserDetailsServiceImpl上面的類中硬編碼了兩個用戶),并確保Content-Type將頭中的用戶分配給application/json

現(xiàn)在,我們可以向標(biāo)頭中的令牌傳遞令牌服務(wù)請求。

如果為管理員用戶創(chuàng)建了令牌,那么您應(yīng)該能夠訪問圖庫服務(wù)的管理區(qū)域。

同樣,如果您正在運行多個gallery服務(wù)實例,每個實例都在不同的端口運行,那么請求將在它們之間平均分配。