SpringBoot2.0實戰 | 第二十七章:整合SpringSecurity之前後端分離使用Token實現登錄鑑權

通過前一篇文章,我們已經實現了前後端分離模式下,使用JSON數據進行前後端交互

主要涉及到

  • 實現 AbstractAuthenticationProcessingFilter 接口,接收JSON格式登錄表單數據,執行登錄校驗
  • 實現 AuthenticationSuccessHandler 接口,登錄成功,返回JSON格式信息
  • 實現 AuthenticationFailureHandler 接口,登錄失敗,返回JSON格式錯誤信息
  • 實現 AccessDeniedHandler 接口,登錄成功,但無資源訪問權限時,返回JSON格式錯誤信息
  • 實現 AuthenticationEntryPoint 接口,未登錄訪問資源時,返回JSON格式錯誤信息

要實現前後端分離,還有一個重要的環節就是存儲用戶登錄狀態,
在前一篇文章中,雖然我們實現的JSON格式交互,但是依然使用 session 存儲用戶登錄狀態,
但是在實際項目中,客戶端不再是單純的網頁,還可以是手機,平板,公衆號,小程序等,
不是每一個客戶端都能夠支持 session+cookie 的模式,怎麼樣可以使用一套代碼,實現多個客戶端登錄、鑑權。

使用 token 代替 session 的流程:
用戶登錄成功,服務端向客戶端分發一個 token,
客戶端根據自已的情況自行存儲,並且在每一次請求中附帶上該 token,
服務端接收到請求,對該 token 進行校驗,判斷請求用戶登錄狀態,獲取權限信息,實現權限校驗。

目標

整合 SpringSecurity 實現使用 token 進行鑑權。

思路

分成兩個部分,第一部分是登錄,客戶端向服務端發起登錄請求時,
服務端需要生成token並存儲起來,然後將token分發給客戶端,客戶端需要自行存儲該token。

流程圖:

客戶端服務端數據庫提交表單生成token存儲token分發token客戶端服務端數據庫




在第二部分是登錄成功後,訪問資源時,客戶端需要將登錄時收到的token,附加在請求中,發送給服務端,
服務端需要判斷該token的有效性,並通過該token,可以獲取到當前用戶信息。

流程圖:

客戶端服務端數據庫訪問資源獲取token通過token獲取用戶信息客戶端服務端數據庫



本文使用數據庫存儲token,實際項目中會使用 redis 之類的更高效的存儲


準備工作

創建用戶表 user、角色表 role、用戶角色關係表 user_roletoken

CREATE TABLE `role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `rolename` varchar(32) NOT NULL COMMENT '角色名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COMMENT='角色';

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(32) NOT NULL COMMENT '用戶名',
  `password` varchar(128) NOT NULL COMMENT '密碼',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COMMENT='用戶';

CREATE TABLE `user_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL,
  `role_id` bigint(20) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `user_id` (`user_id`,`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COMMENT='用戶角色關係表';

CREATE TABLE `token` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `token` varchar(128) NOT NULL COMMENT 'token',
  `user_id` bigint(20) NOT NULL COMMENT '用戶ID',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COMMENT='token';

操作步驟

添加依賴

引入 Spring Boot Starter 父工程

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.5.RELEASE</version>
</parent>

添加 springSecuritymybatisPlus 的依賴,添加後的整體依賴如下

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.2.0</version>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>

配置

配置一下數據源

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf8&useSSL=false
    username: app
    password: 123456

編碼

實體類

角色實體類 Role,實現權限接口 GrantedAuthority

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("role")
public class Role implements GrantedAuthority {

    @TableId(type = IdType.AUTO)
    private Long id;
    private String rolename;

    @Override
    public String getAuthority() {
        return this.rolename;
    }
}

用戶實體類 user,實現權限接口 UserDetails,主要方法是 getAuthorities,用於獲取用戶的角色列表

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("user")
public class User implements UserDetails {

    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String password;
    @TableField(exist = false)
    private List<Role> roleList;
    @TableField(exist = false)
    private String token;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roleList;
    }

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

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

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

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

}

用戶角色關係實體

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("user_role")
public class UserRole {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId;
    private Long roleId;
}

Token實體

@Data
@TableName("token")
public class Token {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String token;
    private Long userId;
}
Repository 層

分別爲四個實體類添加 Mapper

@Mapper
public interface RoleRepository extends BaseMapper<Role> {
}
@Mapper
public interface UserRepository extends BaseMapper<User> {
}
@Mapper
public interface UserRoleRepository extends BaseMapper<UserRole> {
}
@Mapper
public interface TokenRepository extends BaseMapper<Token> {
}

權限配置

實現 UserDetailsService 接口

UserDetailsService 是 SpringSecurity 提供的登陸時用於根據用戶名獲取用戶信息的接口

@AllArgsConstructor
@Service
public class UserService implements UserDetailsService {

    private UserRepository userRepository;
    private RoleRepository roleRepository;
    private UserRoleRepository userRoleRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username == null || username.isEmpty()) {
            throw new UsernameNotFoundException("用戶名不能爲空");
        }
        User user = userRepository.selectOne(
                new QueryWrapper<User>().lambda().eq(User::getUsername, username));
        if (user == null) {
            throw new UsernameNotFoundException("用戶不存在");
        }
        List<UserRole> userRoles = userRoleRepository.selectList(
                new QueryWrapper<UserRole>().lambda().eq(UserRole::getUserId, user.getId()));
        if (userRoles != null && !userRoles.isEmpty()) {
            List<Long> roleIds = userRoles.stream()
                                        .map(UserRole::getRoleId)
                                        .collect(Collectors.toList());
            List<Role> roles = roleRepository.selectList(
                    new QueryWrapper<Role>().lambda().in(Role::getId, roleIds));
            user.setRoleList(roles);
        }
        return user;
    }

}
自定義登錄參數格式
@Data
public class LoginDto {

    private String mobile;
    private String password;
    private String dycode;

}
自定義登錄鑑權過濾器

繼承 SpringSecurity 提供的 AbstractAuthenticationProcessingFilter 類,實現 attemptAuthentication 方法,用於登錄校驗。
本例中,模擬前端使用 json 格式傳遞參數,所以通過 objectMapper.readValue 的方式從流中獲取入參,之後借用了用戶名密碼登錄的校驗,
如果鑑權成功,則生成 token 存庫並將 token 返回給前端。

@Data
public class JsonAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private TokenRepository tokenRepository;

    public JsonAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, 
                                                HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        try {
            LoginDto loginUser = new ObjectMapper().readValue(request.getInputStream(), LoginDto.class);
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                    loginUser.getMobile(), loginUser.getPassword());
            Authentication authenticate = getAuthenticationManager().authenticate(token);
            if (authenticate.isAuthenticated()) {
                User user = (User) authenticate.getPrincipal();
                Token token = new Token();
                token.setToken(UUID.randomUUID().toString());
                token.setUserId(user.getId());
                tokenRepository.insert(token);
                user.setToken(token.getToken());
            }
            return authenticate;
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}
自定義登陸成功後處理

實現 SpringSecurity 提供的 AuthenticationSuccessHandler 接口,使用 JSON 格式返回

@AllArgsConstructor
public class JsonLoginSuccessHandler implements AuthenticationSuccessHandler {

    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }

}
自定義登陸失敗後處理

實現 SpringSecurity 提供的 AuthenticationFailureHandler 接口,使用 JSON 格式返回

public class JsonLoginFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, 
                                        AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.getWriter().write("{\"message\":\"" + exception.getMessage() + "\"}");
    }

}
自定義權限校驗失敗後處理

登陸成功之後,訪問接口之前 SpringSecurity 會進行鑑權,如果沒有訪問權限,需要對返回進行處理。
實現 SpringSecurity 提供的 AccessDeniedHandler 接口,使用 JSON 格式返回

public class JsonAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException exception) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.getWriter().write("{\"message\":\"" + exception.getMessage() + "\"}");
    }

}
自定義未登錄後處理

實現 SpringSecurity 提供的 AuthenticationEntryPoint 接口,使用 JSON 格式返回

public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.getWriter().write("{\"message\":\"" + exception.getMessage() + "\"}");
    }

}
自定義 Token 驗證過濾器

客戶端登錄成功時,後臺會把生成的 token 返回給前端,之後客戶端每次請求後臺接口將會把這個 token 附在 header 頭中傳遞給後臺,
後臺會驗證這個 token 是否有效,如果有效就把用戶信息加載至 SpringSecurity 中,如果無效則會跳轉至上一步提供 AuthenticationEntryPoint 進行處理。

public class TokenAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private TokenRepository tokenRepository;
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private RoleRepository roleRepository;
    @Autowired
    private UserRoleRepository userRoleRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, FilterChain filterChain) 
                        throws ServletException, IOException {
        String tokenStr = request.getHeader("token");
        if (tokenStr != null && !tokenStr.isEmpty()) {
            Token tokenDb = tokenRepository.selectOne(
                    new QueryWrapper<Token>().lambda().eq(Token::getToken, tokenStr));
            if (tokenDb != null && tokenDb.getUserId() != null) {
                User user = userRepository.selectById(tokenDb.getUserId());
                if (user == null) {
                    throw new UsernameNotFoundException("token已失效");
                }
                List<UserRole> userRoles = userRoleRepository.selectList(
                        new QueryWrapper<UserRole>().lambda().eq(UserRole::getUserId, user.getId()));
                if (userRoles != null && !userRoles.isEmpty()) {
                    List<Long> roleIds = userRoles.stream()
                                            .map(UserRole::getRoleId)
                                            .collect(Collectors.toList());
                    List<Role> roles = roleRepository.selectList(
                            new QueryWrapper<Role>().lambda().in(Role::getId, roleIds));
                    user.setRoleList(roles);
                }
                user.setToken(tokenStr);
                UsernamePasswordAuthenticationToken authentication = 
                        new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                logger.info(String.format("Authenticated user %s, setting security context", user.getUsername()));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);
    }

}
註冊

在 configure 方法中將自定義的 jsonAuthenticationFilter 及 tokenAuthenticationFilter 註冊進 SpringSecurity 的過濾器鏈中,
並禁用 session。

@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService userService;
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
            .and().csrf().disable()// 禁用 csrf
                // 禁用 session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .exceptionHandling()
                    .authenticationEntryPoint(new JsonAuthenticationEntryPoint())
                    .accessDeniedHandler(new JsonAccessDeniedHandler())
            .and()
                .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterAfter(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter();
    }

    @Bean
    public JsonAuthenticationFilter jsonAuthenticationFilter() throws Exception {
        JsonAuthenticationFilter filter = new JsonAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(jsonLoginSuccessHandler());
        filter.setAuthenticationFailureHandler(new JsonLoginFailureHandler());
        return filter;
    }

    @Bean
    public JsonLoginSuccessHandler jsonLoginSuccessHandler() {
        return new JsonLoginSuccessHandler(objectMapper);
    }
}

啓動類

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

驗證結果

初始化數據

執行測試用例進行初始化數據

@Slf4j
@RunWith(SpringRunner.class)
@WebAppConfiguration
@SpringBootTest(classes = Application.class)
public class SecurityTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserRoleRepository userRoleRepository;

    @Autowired
    private RoleRepository roleRepository;

    @Test
    public void initData() {
        List<User> userList = new ArrayList<>();
        userList.add(new User(1L, "admin", new BCryptPasswordEncoder().encode("123456"), null));
        userList.add(new User(2L, "user", new BCryptPasswordEncoder().encode("123456"), null));

        List<Role> roleList = new ArrayList<>();
        roleList.add(new Role(1L, "ROLE_ADMIN"));
        roleList.add(new Role(2L, "ROLE_USER"));

        List<UserRole> urList = new ArrayList<>();
        urList.add(new UserRole(1L, 1L, 1L));
        urList.add(new UserRole(2L, 1L, 2L));
        urList.add(new UserRole(3L, 2L, 2L));

        userList.forEach(userRepository::insert);
        roleList.forEach(roleRepository::insert);
        urList.forEach(userRoleRepository::insert);
    }

}

源碼地址

本章源碼 : https://gitee.com/gongm_24/spring-boot-tutorial.git

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章