---
# System prepended metadata

title: SpringSecurity+JWT

---

# 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做非對稱加密的作用應該是比較偏向多服務間的後端溝通，不是前後端溝通(?

## 流程 
我自己的理解，概念會像是這樣
![image](https://hackmd.io/_uploads/BkwkHw44a.png =70%x)
<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都是手動解析操作內容

![image](https://hackmd.io/_uploads/ByroYnPCp.png)
▲找資料的時候找到這張圖，就是框架背後做的事，講得蠻好的
詳細來源 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>==
![image](https://hackmd.io/_uploads/ry1ptq34p.png)
雖然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>