SpringBoot 整合 SpringSecurity 梳理

AaronLin發表於2021-08-24

文件

Spring Security Reference
SpringBoot+SpringSecurity+jwt整合及初體驗
JSON Web Token 入門教程 - 阮一峰
JWT 官網

SpringSecurity

專案 GitHub 倉庫地址:https://github.com/aaronlinv/springsecurity-jwt-demo

依賴

主要用到了: SpringSecurity,Thymeleaf,Web,Lombok

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
</dependency>

頁面

編寫頁面和 Controller 進行測試,具體頁面可以看 程式碼
主要包含了首頁(index),訂單(order),還有 user,role,menu這三個位於 /system 下,需要 admin 許可權

使用記憶體使用者進行表單登入

static 下新建 login.html,用於登入

<form action="/login" method="post">
    <label for="username">賬戶</label><input type="text" name="username" id="username"><br>
    <label for="password">密碼</label><input type="password" name="password" id="password"><br>
    <input type="submit" value="登入">
</form>

編寫繼承 WebSecurityConfigurerAdapter 的 Security 配置類,並開啟 @EnableWebSecurity 註解,這個註解包含了 @Configuration
WebSecurityConfigurerAdapter 中有兩個方法,它們名稱相同,但是入參不同

protected void configure(HttpSecurity http) throws Exception
protected void configure(AuthenticationManagerBuilder auth) throws Exception

入參為 HttpSecurity 的 configure 可以配置攔截相關的引數
另一個入參為 AuthenticationManagerBuilder,則是用來配置驗證相關的引數

@EnableWebSecurity
// @Configuration 被包括在上面的註解了
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    // 配置 PasswordEncoder 用於密碼的加密和匹配
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 
        http
            // 配置表單登入相關引數
            .formLogin()
                // 登入頁面
                .loginPage("/login.html")
                // 表單提交的地址
                .loginProcessingUrl("/login")
                // 登入成功後跳轉的地址
                .defaultSuccessUrl("/index")

            // .and() 方法返回的是 HttpSecurity 物件
            .and()
                // 配置許可權相關引數
                .authorizeRequests()
                // 匹配路徑
                // 需要開放登入的地址,否則訪問登入頁面時因為沒有許可權,自動跳轉到登入頁,進入死迴圈,導致報錯:重定向的次數過多
                .antMatchers("/login.html", "/login")
                // 允許訪問
                .permitAll()

                // 匹配路徑
                .antMatchers("/order")
                // 必須有指定的任意許可權才能訪問
                .hasAnyAuthority("ROLE_user", "ROLE_admin")

                // 匹配 /system 下的所有路徑
                .antMatchers("/system/**")
                // 擁有指定角色才能訪問
                .hasRole("admin")

                // 除了上面的路徑,其他都需要認證
                .anyRequest().authenticated()

            // 返回 HttpSecurity 物件
            .and()
                // 關閉 csrf (跨站請求偽造)
                .csrf().disable();

        // 設定 登出地址
        http.logout().logoutUrl("/logout");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 配置驗證
        // 使用記憶體(非持久化)驗證
        auth.inMemoryAuthentication()
                // 配置使用者名稱
                .withUser("user")
                // 配置用 PasswordEncoder 加密後的密碼
                .password(passwordEncoder().encode("1234"))
                // 配置角色
                .roles("user")
                .and()

                .withUser("admin")
                .password(passwordEncoder().encode("1234"))
                .roles("admin")

                .and()
                // 配置授權時預設使用的 PasswordEncoder
                .passwordEncoder(passwordEncoder());
        ;
    }
}

具體程式碼參考 這裡
兩個 configure 非常類似,入參物件的方法中包含了具體的配置項,如:formLogin,authorizeRequests,csrf,logout 等等,部分配置項還可以通過鏈式呼叫,進行該配置項更詳細地配置,通過 .and() 可以回到 HttpSecurity 物件,再定義其他配置項

使用表單的方式登入需要配置:表單 (formLogin)、授權(authorizeRequests) 、跨站請求偽造(csrf)、登出(logout),還需要配置驗證,先使用最簡單的 inMemoryAuthentication,並指定賬戶密碼,再指定密碼編碼器

然後啟動服務,訪問登入頁面(注意這裡的被修改為 8081),輸入不同的賬號密碼,測試不同頁面的訪問情況,沒有許可權會提示:403
http://localhost:8081/login.html

使用 Json 傳遞引數,自定義 Handler

修改登入頁面,使用 Ajax 向後端傳遞 賬戶和密碼,需要使用 POST

<head>
    <meta charset="UTF-8">
    <title>登入</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>

<form action="/login" method="post">
    <label for="username">賬戶</label><input type="text" name="username" id="username"><br>
    <label for="password">密碼</label><input type="password" name="password" id="password"><br>
    <input type="submit" onclick="login()" value="登入">
</form>
</body>
<script>
    function login() {
        $.ajax({
            type: "POST",
            url: "/login",
            data: {
                "username": $("#username").val(),
                "password": $("#password").val(),
            },
            success: function (data) {
                if (data.code == 20001) {
                    Location.href = "/index";
                } else {
                    alert(data.msg);
                }
            }
        })
    }
</script>

需要編寫登入成功和登入失敗時呼叫的 Handler,並配置到SecurityConfig 中

@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json; charset=UTF-8");
        PrintWriter writer = response.getWriter();
        writer.write("{\"code\":\"40001\",\"msg\":\"登入失敗\"}");
        writer.flush();
        writer.close();
    }
}
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json; charset=UTF-8");
        PrintWriter writer = response.getWriter();
        writer.write("{\"code\":\"20001\",\"msg\":\"登入成功\"}");
        writer.flush();
        writer.close();
    }
}

在 SecurityConfig 中 注入並配置 Handler

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                // 指定 Handler
                .successHandler(successHandler)
                .failureHandler(failureHandler)
                // 省略其他程式碼...
    }

具體程式碼參考 這裡
登入頁面進行測試:http://localhost:8081/login.html
首頁:http://localhost:8081/

基於資料庫的認證

建立資料庫 jwt_demo ,匯入表資料:sql 指令碼
users 表,包括欄位:user_id,user_name,password,status,roles
匯入 MySQL 驅動和 JPA 的依賴

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

在 application.properties 中配置資料庫資訊

server.port=8081
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.url=jdbc:mysql://localhost:3306/jwt_demo?serverTimezone=GMT%2B8&characterEncoding=utf-8

UserDetails 介面是 SpringSecurity 用來承載使用者資訊的載體,SpringSecurity 提供了對這個介面的實現類:org.springframework.security.core.userdetails.User,我們自己定義的使用者類通常也叫User,所以導包時候要注意使用 我們自己定義的 User 類

@Entity
@Table(name = "users")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long userId;

    @Column(name = "user_name")
    private String userName;

    @Column(name = "password")
    private String password;

    @Column(name = "status")
    private String status;

    @Column(name = "roles")
    private String roles;

    // 物件的許可權列表,不需要持久化
    @Transient
    private List<GrantedAuthority> authorities;

    public void setAuthorities(List<GrantedAuthority> authorities) {
        this.authorities = authorities;
    }

    // 必須重寫介面的對於 getPassword,getUsername,getAuthorities 等方法
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getUsername() {
        return this.userName;
    }

    // 下面 4 個需要方法 return true,否則登入時會被限制
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

定義 JPA 的 Repository

@Repository
public interface UserDao extends JpaRepository<User, Long> {
}

定義 Service

public interface UserService {
    public User selectUserByUserName(String username);
}

定義 Service 對應的實現,通過查詢使用者名稱獲得使用者相關資訊

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserDao userDao;

    @Override
    public User selectUserByUserName(String username) {
        User user = new User();
        user.setUserName(username);
        List<User> list = userDao.findAll(Example.of(user));
        return list.isEmpty() ? null : list.get(0);
    }
}

還需要編寫 UserDetailService,供 SpringSecurity 的 DaoAuthenticationProvider 類中的 retrieveUser 方法呼叫,以此獲得對應使用者的資訊

@Service
public class UserDetailService implements UserDetailsService {
    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 呼叫 Service
        User user = userService.selectUserByUserName(username);
        if (user == null) {
            throw new UsernameNotFoundException("使用者" + user.getUsername() + "不存在");
        }
        // 設定許可權
        // commaSeparatedStringToAuthorityList 方式將字串間通過 ',' 進行分割,然後返回 List
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
        return user;
    }
}
// 省略其他...
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailService userDetailService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 將記憶體授權方式替換為自己實現的 UserDetailService
        auth.userDetailsService(userDetailService)
                .passwordEncoder(passwordEncoder());
    // 省略其他...
}

具體程式碼參考 這裡

登入頁面進行測試:http://localhost:8081/login.html
首頁:http://localhost:8081/

整合 JWT

新增 jjwt 依賴

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>

在 application.properties 中配置 JWT 引數

token.header:Authorization
#  令牌祕鑰
token.secret:askdhfkjahskjdfhkalsjhdf^112asdfasdf44^%$_@+asdfasdfaskjdhfkjashdfljkahsdklsfjasgdkjfgjahs(IS:)_@@+asdfasdfaskjdhfkjashdfljkahsdklsfja@+asdfasdfaskjdhfkjashdfljkahsdklsfjasgdkjfgjahssgdkjfgjahsdgfjhgsdfsadf+-asdfasdas+as++_sdfsdsasdfasdf
#   令牌有效期(預設30分鐘)
token.expireTime:3600000

定義統一 API 封裝格式

public class RestResult extends HashMap<String, Object> {
    private static final long serialVersionUID = 1L;
    // 狀態碼
    public static final String CODE_TAG = "code";
    // 返回內容
    public static final String MSG_TAG = "msg";
    // 資料物件
    public static final String DATA_TAG = "data";

    public RestResult() {

    }

    public RestResult(int code, String msg) {
        super.put(CODE_TAG, code);
        super.put(MSG_TAG, msg);
    }

    public RestResult(int code, String msg, Object data) {
        super.put(CODE_TAG, code);
        super.put(MSG_TAG, msg);
        if (data != null) {
            super.put(DATA_TAG, data);
        }
    }

    public static RestResult success() {
        return new RestResult(200, "成功");
    }
}

然後準備 JWT 工具類,實現:生成 token、從 token 中獲取使用者名稱、檢查 token 是否過期、重新整理 token、驗證 token 等,這裡的 KEY 通過雙重鎖 保證了執行緒安全

@Data
@Component
@Slf4j
public class JwtTokenUtils {
    @Value("${token.secret}")
    private String secret;

    @Value("${token.expireTime}")
    private Long expiration;

    @Value("${token.header}")
    private String header;

    private static Key KEY = null;

    /**
     * 生成token令牌
     *
     * @param userDetails 使用者
     * @return 令token牌
     */
    public String generateToken(UserDetails userDetails) {
        log.info("[JwtTokenUtils] generateToken " + userDetails.toString());
        Map<String, Object> claims = new HashMap<>(2);
        claims.put("sub", userDetails.getUsername());
        claims.put("created", new Date());

        return generateToken(claims);
    }

    /**
     * 從令牌中獲取使用者名稱
     *
     * @param token 令牌
     * @return 使用者名稱
     */
    public String getUsernameFromToken(String token) {
        String username = null;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.get("sub", String.class);
            log.info("從令牌中獲取使用者名稱:" + username);
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 判斷令牌是否過期
     *
     * @param token 令牌
     * @return 是否過期
     */
    public Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 重新整理令牌
     *
     * @param token 原令牌
     * @return 新令牌
     */
    public String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);
            claims.put("created", new Date());


            refreshedToken = generateToken(claims);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 驗證令牌
     *
     * @param token 令牌
     * @param userDetails 使用者
     * @return 是否有效
     */
    public Boolean validateToken(String token, UserDetails userDetails) {

        String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) &&
                !isTokenExpired(token));
    }

    /**
     * 從claims生成令牌
     *
     * @param claims 資料宣告
     * @return 令牌
     */
    private String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + expiration);
        return Jwts.builder().setClaims(claims)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS256, getKeyInstance())
                .compact();
    }

    /**
     * 從令牌中獲取資料宣告
     *
     * @param token 令牌
     * @return 資料宣告
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims = null;

        try {
            claims = Jwts.parser().setSigningKey(getKeyInstance()).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

    private Key getKeyInstance() {
        if (KEY == null) {
            synchronized (JwtTokenUtils.class) {
                if (KEY == null) {// 雙重鎖
                    byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);
                    KEY = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
                }
            }
        }
        return KEY;
    }
}

然後定義 JwtAuthTokenFilter,用於過濾請求

@Component
public class JwtAuthTokenFilter extends OncePerRequestFilter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtTokenUtils jwtTokenUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        // 從請求頭中獲取 Authorization 的值,即 token
        String jwtToken = request.getHeader(jwtTokenUtils.getHeader());

        if (!ObjectUtils.isEmpty(jwtToken)) {
            // 從 token 中獲取使用者名稱,使用者名稱儲存在負載中,負載一般沒有加密,所以負載的內容是可以見,不能在其中存放敏感資訊
            // 可以通過 https://jwt.io/ 進行解碼
            String username = jwtTokenUtils.getUsernameFromToken(jwtToken);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 通過 userDetailsService 從資料庫中獲取對應使用者的資訊
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                // 這裡校驗 token 有效性
                if (jwtTokenUtils.validateToken(jwtToken, userDetails)) {
                    // 將 UserDetails 物件 封裝為 UsernamePasswordAuthenticationToken 物件
                    // 第一引數是 Object principal,傳入的是 UserDetails 物件,在後面的 Service 中會取出 principal
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    // 交給SpringSecurity管理,在之後的過濾器不會被攔截進行二次授權了
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }
        // 將請求轉發給過濾器鏈上的下一個物件
        chain.doFilter(request, response);
    }
}

編寫 JwtAuthService,處理登入的相關邏輯,使用 AuthenticationManager 對傳入的賬號密碼進行認證,成功返回 生成的 token

@Service
public class JwtAuthService {
    @Autowired
    private JwtTokenUtils jwtTokenUtils;

    @Autowired
    private AuthenticationManager authenticationManager;

    public String login(String username, String password) {
        Authentication authentication = null;
        try {
            authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            throw new RuntimeException("使用者名稱或密碼有誤");
        } 
        // 這裡就是獲取的就是在前面 JwtAuthTokenFilter 中傳入的 principal
        User loginUser = (User) authentication.getPrincipal();
        return jwtTokenUtils.generateToken(loginUser);
    }
}

用於登入的 Controller

@RestController
public class JwtLoginController {
    @Autowired
    private JwtAuthService jwtAuthService;

    @PostMapping({"/login", "/"})
    public RestResult login(String username, String password) {
        RestResult result = RestResult.success();
        String token = jwtAuthService.login(username, password);
        result.put("token", token);
        return result;
    }
}

在 SecurityConfig 中 注入並配置 Handler

    // 省略其他程式碼...
    @Autowired
    private JwtAuthTokenFilter jwtAuthTokenFilter;

    // 重寫 AuthenticationManager,避免報錯
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // http.formLogin()
        //         .loginPage("/login.html")
        //         .loginProcessingUrl("/login")
        //         // .defaultSuccessUrl("/index")
        //         // .defaultSuccessUrl("/index")
        //         .successHandler(successHandler)
        //         .failureHandler(failureHandler)
        http.sessionManagement()
                // 不建立和使用 session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .authorizeRequests()
                .antMatchers("/login")
                .anonymous()

                .antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js")
                .permitAll()
        // 省略其他程式碼...

        // 使用 JWT 過濾器
        http.addFilterBefore(jwtAuthTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
     // 省略其他程式碼...

可以通過 Postman 先指定引數(注意是用 POST),獲取 token:
http://localhost:8081/login?username=user&password=1234

在 Headers 中新增 Authorization,值為獲取到的 token
使用 GET 訪問:http://localhost:8081/order
因為 user 沒有管理許可權,所以訪問管理頁面會 403:http://localhost:8081/system/role

具體程式碼參考 這裡

相關文章