# SpringSecurity+JWT實現登入註冊/後續資源存取
## 簡述
專案在此 https://github.com/ryanimay/erp
<font color="#f00">(如果有疑問,詳細部分要讀sourceCode,筆記有些是舊的程式碼沒有更新)</font>
--
大致概念就是
SpringSecurity負責用戶登入的帳號密碼驗證(包含加密解密),還有用戶權限驗證
JWT實現已登入用戶持續存取資源的驗證(驗證Token有效性確保是經過認證的用戶)
2024.3.19
後端部分API大致完成
總結對SpringSecurity+JWT的實現以及理解
先實現<font color="#f00">UserDetails</font>,用於驗證該用戶狀態及權限
先實現<font color="#f00">UserDetailsService</font>,用於登入驗證時使用userName找到該用戶,返回UserDetails實現類
把先前實現的UserDetailsService類注入<font color="#f00">AuthenticationProvider</font>(DaoAuthenticationProvider)
<font color="#f00">SecurityFilterChain</font>設置.addFilterBefore(<font color="#f00">JwtFilter</font>, UsernamePasswordAuthenticationFilter.class)
之後登入就會先進行JWT驗證才會走到security的權限驗證
這邊的API權限設置也都是用動態加仔的方式設置在SecurityFilterChain
後續流程:
用戶註冊時,密碼經由指定的<font color="#f00">PasswordEncoder</font>(像這邊是使用BCryptPasswordEncoder)
加密後存入資料庫
後續用戶發起登入請求,把帳密放進UsernamePasswordAuthenticationToken
進行.authenticate(會使用先前實現的UserDetailsService類進行驗證)
驗證帳號密碼是否通過和驗證該用戶相關狀態
如果通過就產出並核發JWT返回,並且因為SecurityContext只保留再請求的生命週期間
之後登入成功後的每個請求流程都是:
Filter先驗證AccessToken是否過期,
如果過期就再驗證RefreshToken是否過期和是否在黑名單內
(如果登入沒有勾選rememberMe,這邊就會直接拋出要求重登)
如果通過驗證則把AccessToken和RefreshToken都刷新,並且把舊Token加入Redis黑名單
如果RefreshToken也過期,就直接拋出,返回Unauthorized
驗證過程中只要是過期以外的錯誤都是直接返回403
並且只要沒有拋出(JWT驗證通過)
就必須透過JWT內儲存的用戶資訊產出Authentication以利該請求進行後續權限驗證
(目前是只存用戶名稱,想秉持用戶驗證和權限驗證的獨立性但不確定優劣)
然後因為是每次用戶請求都會產生新的Authentication,所以權限是即時更新的
但缺點就是每次請求都要查詢當下權限,目前是只想到利用緩存優化
另外
~~這邊JWT設計是用非對稱式加密
公鑰給前端,前後都需要驗證JWT時效和簽名~~
2024.4.24:
我的理解錯誤,這邊目前是修改為前端只進行時效驗證因為不需要密鑰
簽名驗證因為有密鑰隱私問題,由後端負責
JWT做非對稱加密的作用應該是比較偏向多服務間的後端溝通,不是前後端溝通(?
## 流程
我自己的理解,概念會像是這樣

<b>請求進來
-> 封裝成驗證用的物件
-> AuthenticationProvider.authenticate()負責驗證,內含passwordEncoder和資料庫搜尋用戶
-> 驗證完成取出用戶資訊,並加工產生Token
-> 封裝成response並返回</b>
```java=
// 封裝帳密
Authentication authentication = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
// security執行帳密認證(會使用authenticationProvider設置的加密類還有UserDetailService實現)
authentication = authenticationProvider.authenticate(authentication);
// 認證成功後取得結果
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 產token
String accessToken = createToken(ACCESS_TOKEN, userDetails.getUsername(), ACCESS_TOKEN_EXPIRE_TIME);
String refreshToken = createToken(REFRESH_TOKEN, userDetails.getUsername(), REFRESH_TOKEN_EXPIRE_TIME);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken);
httpHeaders.add(REFRESH_TOKEN, refreshToken);
```
<font color="#f00">最後返回前端的是封裝的Token(令牌),但安全性不受保護(只是base64編碼,不是加密),
所以盡量不要存重要資訊</font>
### SecurityContext
用於保存當前線程資訊,但生命週期僅限當前線程,可以用
SecurityContext securityContext = SecurityContextHolder.getContext();獲得
可以做
securityContext.setAuthentication(authentication)或
securityContext.getAuthentication(authentication)
在線程內獲取任何常用資訊比如說用戶資訊
這邊是在用戶呼叫接口時filter先放入Authentication,後續存儲資源時可以任意調用
### Authentication
主要分為三部份
(principal, credentials, authorities)
就是
(用戶名(或用戶資訊), 用戶驗證(密碼), 用戶權限)
這邊的用法,除了登入驗證時的UsernamePasswordAuthenticationToken有作用
後續就只是用來程載用戶資訊(UserDetails)的容器,放在principal並且存於SecurityContext
沒有特別使用JwtAuthenticationToken做JWT驗證
後續JWT都是手動解析操作內容

▲找資料的時候找到這張圖,就是框架背後做的事,講得蠻好的
詳細來源 https://www.cnblogs.com/auguse/articles/17654716.html
## 實現
### dependency
```java=
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--JWT-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
```
這邊使用是結合JWT(<font color="#f00">先不做OAuth2.0,沒有三方登入需求,目前設計是像session刷新那樣,用RefreshToken刷新AccessToken時間</font>),這邊是使用jjwt
### UserDetails
```java=
@Getter
@Setter
@NoArgsConstructor
public class UserDetailImpl implements UserDetails {
private Locale locale;
private ClientModel clientModel;
@JsonIgnore
private CacheService cacheService;
public UserDetailImpl(ClientModel clientModel, CacheService cacheService) {
this.cacheService = cacheService;
this.clientModel = clientModel;
}
@Override
@JsonIgnore
public String getUsername() {
return clientModel.getUsername();
}
@Override
@JsonIgnore
public String getPassword() {
return clientModel.getPassword();
}
@Override
@JsonIgnore
public boolean isEnabled() {
return clientModel.isActive();
}
@Override
@JsonIgnore
public boolean isAccountNonLocked() {
return !clientModel.isLock();
}
@Override
@JsonIgnore
public boolean isCredentialsNonExpired() {
return true;
}
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
return getRolePermission(clientModel.getRoles());
}
@Override
@JsonIgnore
public boolean isAccountNonExpired() {
return true;
}
private Collection<? extends GrantedAuthority> getRolePermission(Set<RoleModel> roles) {
Set<RolePermissionDto> set = new HashSet<>();
for (RoleModel role : roles) {
set.addAll(cacheService.getRolePermission(role.getId()));
}
return set;
}
}
```
就是實現UserDetails類
看內容就知道,主要就是@Override的方法用於驗證用戶狀態以及權限
其餘就只是邏輯的實現
### UserDetailsService
```java=
@Service
public class UserDetailServiceImpl implements UserDetailsService {
LogFactory LOG = new LogFactory(UserDetailServiceImpl.class);
private ClientService clientService;
private UserDetailFactory userDetailFactory;
@Autowired
public void setClientService(ClientService clientService) {
this.clientService = clientService;
}
@Autowired
public void setUserDetail(UserDetailFactory userDetailFactory) {
this.userDetailFactory = userDetailFactory;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ClientModel client = clientService.findByUsername(username);
if (client == null) {
LOG.warn("Cant find user: {0}", username);
throw new UsernameNotFoundException("Cant find user:" + username);
}
return userDetailFactory.build(client);
}
}
```
再來是實現UserDetailsService類
重點是loadUserByUsername方法,用於登入時要靠用戶名找到對應用戶(從DB)
注意返回類是UserDetails,就是剛剛做的實現類
### --其餘配置類--
其餘需要配置的有幾個部分,這邊都是統一放在自訂的SecurityConfig類中
```java=
@Configuration
@EnableWebSecurity
public class SecurityConfig {
...
}
```
其內容這邊分別提
### 1. PasswordEncoder
```java=
//就是加密解密的方式,這邊是用BCryptPasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
```
### 2. AuthenticationProvider
```java=
//把剛剛做的UserDetailsService和PasswordEncoder注入AuthenticationProvider
//之後框架驗證就會使用字定義的內容
@Bean
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
```
### 3. SecurityFilterChain
```java=
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter authFilter, UserStatusFilter userStatusFilter, DenyPermissionFilter denyPermissionFilter) throws Exception {
//配置資料庫內permission表的所有API權限設定
configurePermission(http);
//permission表以外的設定
http.authorizeHttpRequests(request -> request
.requestMatchers("/swagger/swagger-ui.html", "/swagger/swagger-ui/**", "/swagger/api-docs/**").permitAll()
.anyRequest().authenticated())
.csrf(AbstractHttpConfigurer::disable)
.addFilterBefore(denyPermissionFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(authFilter, DenyPermissionFilter.class)
.addFilterAfter(userStatusFilter, JwtAuthenticationFilter.class)
.exceptionHandling(exception ->
exception.accessDeniedHandler((request, response, accessDeniedException) ->
FilterExceptionResponse.error(response, ApiResponseCode.ACCESS_DENIED))
.authenticationEntryPoint((request, response, authException) -> {
authException.printStackTrace();
if(authException instanceof LockedException){
FilterExceptionResponse.error(response, ApiResponseCode.CLIENT_LOCKED);
}else if(authException instanceof DisabledException){
FilterExceptionResponse.error(response, ApiResponseCode.CLIENT_DISABLED);
}else{
FilterExceptionResponse.error(response, ApiResponseCode.ACCESS_DENIED);
}
}
));
return http.build();
}
//動態設定所有權限
private void configurePermission(HttpSecurity http) throws Exception {
List<PermissionModel> permissions = permissionService.findAll();
LOG.info("all permission: {0}", ObjectTool.toJson(permissions));
http.authorizeHttpRequests(request -> {
for (PermissionModel permission : permissions) {
String authority = permission.getAuthority();
String url = permission.getUrl();
if("*".equals(authority)){
request.requestMatchers(url).permitAll();//公開api
noRequiresAuthenticationSet.add(url);
}else{
request.requestMatchers(antMatcher(url)).hasAuthority(authority);
}
}
});
}
```
SecurityFilterChain這邊詳細說明,主要就是security框架驗證的鏈
從用戶角色相關到路徑權限驗證,
可以設定每個路徑需求的permission或是role
(這邊是讀資料庫用動態加載的方式設置)
也可以在這個鏈中加入自己定義的Filter
像這邊還加入了[JWT的Filter](###JWTFilter),下面會說明
==關於路徑設定權限還有其他方式(annotation之類的)
可以看這邊 https://www.baeldung.com/spring-security-expressions==
:::danger
==<font color="#f00">強烈注意</font>==

雖然antMatcher已經棄用改成requestMatchers
但原理一樣,如果要用路徑比對權限,
必須按照 精確的路徑需求放前面,大範圍匹配較籠統的放後面
不然按順序掃到全域'/** '不符權限就會拋出了,後面配置的精確路徑權限都會失效
這裡deBug花了兩天...
:::
### JWTFilter
用OncePerRequestFilter
每個請求進來,如果是需要權限的url才會往下做驗證
驗證AccessToken
如果無效或為空就返回403(這邊是自己封裝返回類,詳細實現可以看下面GitCode)
如果過期就驗證RefreshToken
```java=
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
LogFactory LOG = new LogFactory(JwtAuthenticationFilter.class);
private final TokenService tokenService;
private final CacheService cacheService;
public JwtAuthenticationFilter(TokenService tokenService, CacheService cacheService) {
this.tokenService = tokenService;
this.cacheService = cacheService;
}
/**
* 針對已登入用戶後續存取api做token驗證和例外處理
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String url = ObjectTool.extractPath(request.getRequestURI());
if (requiresAuthentication(url) && notEqualWebsocketUrl(url)) {
String token;
//刷Token的請求改成驗證refreshToken
if(Router.CLIENT.REFRESHT.equals(url)){
token = request.getHeader(TokenService.REFRESH_TOKEN);
}else{
token = request.getHeader(HttpHeaders.AUTHORIZATION);
}
if(token == null || cacheService.existsTokenBlackList(token)) throw new ExpiredJwtException(null, null, null);
Map<String, Object> payload = authenticationToken(token);
String uid = String.valueOf(payload.get(TokenService.TOKEN_PROPERTIES_UID));
createAuthentication(Long.parseLong(uid), request);
}else{
createEmptyUserAuth(request);
}
} catch (ExpiredJwtException e) {
exceptionResponse(e, response, ApiResponseCode.INVALID_SIGNATURE);
return;
} catch (SignatureException | AccessDeniedException | MalformedJwtException e) {
exceptionResponse(e, response, ApiResponseCode.ACCESS_DENIED);
return;
} catch (URISyntaxException e) {
LOG.error("request path: [{0}] trans error", request.getRequestURI());
exceptionResponse(e, response, ApiResponseCode.ACCESS_DENIED);
return;
}
filterChain.doFilter(request, response);
}
private boolean requiresAuthentication(String url) {
return !SecurityConfig.noRequiresAuthenticationSet.contains(url);
}
/**
* 驗證AccessToken
*/
private Map<String, Object> authenticationToken(String token) {
String accessToken = token.replace(TokenService.TOKEN_PREFIX, "");
return tokenService.parseToken(accessToken);
}
/**
* 建立ClientIdentity
* */
private void createAuthentication(Long id, HttpServletRequest request) {
String lang = request.getHeader("User-Lang");
ClientIdentityDto client = cacheService.getClient(id);
if(client == null) return;
UserDetailImpl userDetail = lang == null ? new UserDetailImpl(client, cacheService) : new UserDetailImpl(lang, client, cacheService);
Collection<? extends GrantedAuthority> rolePermission = userDetail.getAuthorities();
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetail, null, rolePermission);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
/**
* 在lang不為null的情況下
* 創建不需驗證的api用的auth
* 只放帶有lang的principal,用於控制接口返回語系
* */
private void createEmptyUserAuth(HttpServletRequest request) {
String lang = request.getHeader("User-Lang");
if(lang != null){
UserDetailImpl userDetail = new UserDetailImpl(lang, new ClientIdentityDto(), cacheService);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetail, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
private void exceptionResponse(Exception e, HttpServletResponse response, ApiResponseCode code) throws IOException {
LOG.error(e.getMessage());
FilterExceptionResponse.error(response, code);
}
private boolean notEqualWebsocketUrl(String requestedUrl) {
return !requestedUrl.contains("/ws");
}
```
### TOKEN
這邊做非對稱,公鑰會暴露給前端驗證時效使用
密鑰的部份這邊用動態產生的方式,伺服器重啟用戶就必須重登
```java=
private KeyPair keyPair;
@PostConstruct
public void init(){
keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256); //做非對稱,伺服器重啟時刷新
}
public String createToken(String type, String username, int expirationTime) {
//轉毫秒
long expirationMillis = getExpireMillisecond(expirationTime);
// 設置標準內容與自定義內容
Claims claims = Jwts.claims();
claims.setSubject(type);
claims.setIssuedAt(new Date());
claims.setExpiration(new Date(expirationMillis));
claims.put(TOKEN_PROPERTIES_USERNAME, username);
// 簽名後產生 token
PrivateKey privateKey = keyPair.getPrivate();
return Jwts.builder()
.setClaims(claims)
.signWith(privateKey)
.compact();
}
private Long getExpireMillisecond(int expirationTime){
return Instant.now()
.plusSeconds(expirationTime)
.getEpochSecond()
* 1000;
}
//公鑰解密
public Map<String,Object> parseToken(String token){
PublicKey publicKey = keyPair.getPublic();
Claims claims = Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(token).getBody();
return new HashMap<>(claims);
}
```
## Git
專案在此 https://github.com/ryanimay/erp
<font color="#f00">(詳細部分要讀sourceCode,筆記有些是舊的程式碼沒有更新)</font>