Проекты разделения фронтенда и бэкэнда SpringBoot, интегрированная Spring Security (полная версия)

Spring Boot

В этой статье объясняется использование версии SpringBoot: 2.2.6.RELEASE, версии Spring Security: 5.2.2.RELEASE.

В Java есть два популярных фреймворка безопасности, Apache Shiro и Spring Security, Shiro не очень дружелюбен к проектам разделения front-end и back-end, и в итоге был выбран Spring Security. SpringBoot предоставляет официальныйspring-boot-starter-security, могут быть легко интегрированы в проект SpringBoot, но использование на уровне предприятия все еще нуждается в небольшом преобразовании.В этой статье реализованы следующие функции:

  • Обработка исключений при доступе анонимных пользователей к непривилегированным ресурсам
  • Имеет ли вошедший в систему пользователь разрешение на доступ к ресурсу
  • Распределенный обмен сеансами на основе Redis
  • Обработка тайм-аута сеанса
  • Ограничьте максимальное количество пользователей, одновременно вошедших в систему с одной и той же учетной записью (верхняя учетная запись)
  • Вернуть json после успешного и неудачного входа в систему
  • Поддерживает 3 места хранения токенов одновременно: cookie, http заголовок,request parameter

Быстрое использование, импорт зависимостей

<!--spring security-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- spring session redis -->
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>

spring-boot-starter-securityдля интеграции весенней безопасности,spring-session-data-redisИнтегрируйте redis и spring-session.

Индивидуальный доступ к Spring Security

Цель использования Spring Security — написать как можно меньше кода и реализовать больше функций.Основная идея настройки Spring Security — переписать функцию, а затем настроить ее.

  • Например, если вы хотите проверить свою собственную таблицу пользователей для входа в систему, то реализуйте интерфейс UserDetailsService;
  • Например, внешний и внутренний проекты разделены, и json возвращается после успешного и неудачного входа в систему, затем реализуйте интерфейс AuthenticationFailureHandler/AuthenticationSuccessHandler;
  • Например, чтобы расширить место хранения токенов, реализуйте интерфейс HttpSessionIdResolver;
  • так далее...

Наконец, настройте сделанные выше изменения в безопасности. Рутина такая рутина, давайте попробуем.

Don't bb, show me code.

1. Работа с анонимными пользователями, у которых нет доступа

Реализуйте интерфейс AuthenticationEntryPoint для обработки исключений, когда анонимные пользователи получают доступ к неавторизованным ресурсам, следующим образом:

@Slf4j
@Component
public class AnonymousAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        log.warn("用户需要登录,访问[{}]失败,AuthenticationException={}", request.getRequestURI(), e);

        ServletUtils.render(request, response, RestResponse.fail(ResponseCode.USER_NEED_LOGIN));
    }
}

public class ServletUtils {

    /**
     * 渲染到客户端
     *
     * @param object   待渲染的实体类,会自动转为json
     */
    public static void render(HttpServletRequest request, HttpServletResponse response, Object object) throws IOException {
        // 允许跨域
        response.setHeader("Access-Control-Allow-Origin", "*");
        // 允许自定义请求头token(允许head跨域)
        response.setHeader("Access-Control-Allow-Headers", "token, Accept, Origin, X-Requested-With, Content-Type, Last-Modified");
        response.setHeader("Content-type", "application/json;charset=UTF-8");

        response.getWriter().print(JSONUtil.toJsonStr(object));
    }
}

Следует отметить, что когда в программе возникает ненормальная ошибка (например, 500), она также вводит метод начала.

2. Логика аутентификации пользователя на основе базы данных

Узнайте информацию о пользователе для входа в систему (например, пароль), роли, разрешения и т. д. из базы данных, а затем верните объект типа UserDetails.Безопасность будет автоматически оценивать пользователя в соответствии с паролем и статусом, связанным с пользователем (независимо от того, является ли он заблокирован, запущен или остановлен, истек ли срок его действия и т. д.) Вход в систему выполнен успешно или неудачно.

@Slf4j
@Component
public class DefaultUserDetailsService implements UserDetailsService {

    @Autowired
    private SystemService systemService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (StrUtil.isBlank(username)) {
            log.info("登录用户:{} 不存在", username);
            throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
        }

        // 查出密码
        UserVO userVO = systemService.loadUserByUsername(username);
        if (ObjectUtil.isNull(userVO) || StrUtil.isBlank(userVO.getUserId())) {
            log.info("登录用户:{} 不存在", username);
            throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
        }
        return new LoginUser(userVO, IpUtils.getIpAddr(ServletUtils.getRequest()), LocalDateTime.now(), LoginType.PASSWORD);
    }

}

/**
 * 扩展用户信息
 *
 * @author songyinyin
 * @date 2020/3/14 下午 05:29
 */
@Data
public class LoginUser implements UserDetails, CredentialsContainer {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    /**
     * 用户
     */
    private UserVO user;

    /**
     * 登录ip
     */
    private String loginIp;

    /**
     * 登录时间
     */
    private LocalDateTime loginTime;

    /**
     * 登陆类型
     */
    private LoginType loginType;

    public LoginUser() {
    }

    public LoginUser(UserVO user, String loginIp, LocalDateTime loginTime, LoginType loginType) {
        this.user = user;
        this.loginIp = loginIp;
        this.loginTime = loginTime;
        this.loginType = loginType;
    }

    public LoginUser(UserVO user, String loginIp, LocalDateTime loginTime, String loginType) {
        this.user = user;
        this.loginIp = loginIp;
        this.loginTime = loginTime;
        this.loginType = LoginType.valueOf(loginType);
    }

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

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    /**
     * 账户是否未过期,过期无法验证
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 指定用户是否解锁,锁定的用户无法进行身份验证
     * <p>
     * 密码锁定
     * </p>
     */
    @Override
    public boolean isAccountNonLocked() {
        return ObjectUtil.equal(user.getPwdLockFlag(), LockFlag.UN_LOCKED);
    }

    /**
     * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 用户是否被启用或禁用。禁用的用户无法进行身份验证。
     */
    @Override
    public boolean isEnabled() {
        return ObjectUtil.equal(user.getStopFlag(), StopFlag.ENABLE);
    }

    /**
     * 认证完成后,擦除密码
     */
    @Override
    public void eraseCredentials() {
        user.setPassword(null);
    }
}

В то же время LoginUser также реализует интерфейс CredentialsContainer.После успешной аутентификации пользователя пароль стирается и возвращается во внешний интерфейс.

3. Обработка успешного входа

После успешного входа в систему обычно записывается журнал входа в систему, а затем проверка подлинности пользователя после проверки подлинности возвращается на внешний интерфейс.

@Slf4j
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        // TODO 登录成功 记录日志
        ServletUtils.render(request, response, RestResponse.success(authentication));
    }
}

4. Обработка ошибок входа

После сбоя входа в систему вы можете определить причину сбоя входа в соответствии с различными AuthenticationExceptions.Вам необходимо распечатать журнал здесь, а затем вернуть информацию во внешний интерфейс в соответствии с бизнес-требованиями. Например, требование состоит в том, что независимо от того, в чем заключается ошибка, возвращается ошибка входа.Пример здесь состоит в том, чтобы отличить ошибку входа.

@Slf4j
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        RestResponse result;
        String username = UserUtil.loginUsername(request);
        if (e instanceof AccountExpiredException) {
            // 账号过期
            log.info("[登录失败] - 用户[{}]账号过期", username);
            result = RestResponse.build(ResponseCode.USER_ACCOUNT_EXPIRED);

        } else if (e instanceof BadCredentialsException) {
            // 密码错误
            log.info("[登录失败] - 用户[{}]密码错误", username);
            result = RestResponse.build(ResponseCode.USER_PASSWORD_ERROR);

        } else if (e instanceof CredentialsExpiredException) {
            // 密码过期
            log.info("[登录失败] - 用户[{}]密码过期", username);
            result = RestResponse.build(ResponseCode.USER_PASSWORD_EXPIRED);

        } else if (e instanceof DisabledException) {
            // 用户被禁用
            log.info("[登录失败] - 用户[{}]被禁用", username);
            result = RestResponse.build(ResponseCode.USER_DISABLED);

        } else if (e instanceof LockedException) {
            // 用户被锁定
            log.info("[登录失败] - 用户[{}]被锁定", username);
            result = RestResponse.build(ResponseCode.USER_LOCKED);

        } else if (e instanceof InternalAuthenticationServiceException) {
            // 内部错误
            log.error(String.format("[登录失败] - [%s]内部错误", username), e);
            result = RestResponse.fail(ResponseCode.USER_LOGIN_FAIL);

        } else {
            // 其他错误
            log.error(String.format("[登录失败] - [%s]其他错误", username), e);
            result = RestResponse.fail(ResponseCode.USER_LOGIN_FAIL);
        }
        // TODO 登录失败 记录日志
        ServletUtils.render(request, response, result);
    }
}

5. Обратный вызов выхода из системы

Подобно успешному и неудачному входу в систему, запишите журнал, а затем вернитесь к внешнему интерфейсу json.

@Slf4j
@Component
public class LogoutSuccessHandler implements org.springframework.security.web.authentication.logout.LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        // TODO 登出成功 记录登出日志
        ServletUtils.render(request, response, RestResponse.success());
    }
}

6. Обработка времени ожидания входа в систему

После того, как пользователь входит в систему, когда истекает период ожидания (сеанс истекает), пользователь автоматически выходит из системы.

@Slf4j
@Component
public class InvalidSessionHandler implements InvalidSessionStrategy {

    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        log.info("用户登录超时,访问[{}]失败", request.getRequestURI());
        
        ServletUtils.render(request, response, RestResponse.fail(ResponseCode.USER_LOGIN_TIMEOUT));
    }
}

7. Обработка ограниченного количества пользователей, которые одновременно входят в систему с одной и той же учетной записью.

Например, если количество сессий, в которые одновременно входит пользователь, превышает системную настройку, а родной язык занимает первое место, в это время он будет обрабатываться SessionInformationExpiredStrategy. Кроме того, он также будет активирован после того, как администратор предложит онлайн-пользователя.

@Slf4j
@Component
public class SessionInformationExpiredHandler implements SessionInformationExpiredStrategy {

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException {

        ServletUtils.render(sessionInformationExpiredEvent.getRequest(),
                sessionInformationExpiredEvent.getResponse(), RestResponse.fail(ResponseCode.USER_MAX_LOGIN));
    }
}

8. Внедрение пользовательской аутентификации

Когда пользователь входит в систему, как мы можем определить, есть ли у пользователя разрешение на доступ к ресурсу? Помните, что в **[2. Логике аутентификации пользователя на основе базы данных]** роли разрешений пользователя будут найдены из базы данных, которая обеспечивает основу для нашей текущей аутентификации.

@Slf4j
@Service("ps")
public class PermissionService {

    public boolean permission(String permission) {
        LoginUser loginUser = UserUtil.loginUser();
        for (String userPermission : loginUser.getUser().getPermissions()) {
            if (permission.matches(userPermission)) {
                return true;
            }
        }
        if (log.isDebugEnabled()) {
            log.debug("用户userId={}, userName={} 权限不足以访问[{}], 用户具有权限:{}, 访问", loginUser.getUser().getUserId(),
                    loginUser.getUsername(), permission, loginUser.getUser().getPermissions());
        } else {
            log.info("用户userId={}, userName={} 权限不足以访问[{}]", loginUser.getUser().getUserId(), loginUser.getUsername(), permission);
        }
        return false;
    }
}

@RestController
public class UserController {

    @Autowired
    protected IUserService userService;

    @GetMapping("/user/page")
    @ApiOperation(value = "分页查询用户")
    @PreAuthorize("@ps.permission('system:user:page')")
    public TableResponse<UserVO> page() {
        IPage<User> page = userService.getPage();

        List<UserVO> userVOList = page.getRecords().stream().map(e -> {
            UserVO userVO = new UserVO();
            BeanUtils.copyPropertiesIgnoreNull(e, userVO);
            return userVO;
        }).collect(Collectors.toList());

        return TableResponse.success(page.getTotal(), userVOList);
    }
}

Используйте аннотацию @PreAuthorize для защиты ресурсов вашего приложения. Однако @EnableGlobalMethodSecurity(prePostEnabled = true) необходимо настроить, чтобы @PreAuthorize вступил в силу.

9. Обработка пользователей входа в систему, у которых нет разрешения на доступ

Хотя пользователь вошел в систему, но разрешений недостаточно для доступа к некоторым ресурсам, на этот раз для его обработки необходим AccessDeniedHandler.

@Slf4j
@Component
public class LoginUserAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ServletUtils.render(request, response, RestResponse.build(ResponseCode.NO_AUTHENTICATION));
    }
}

10. Парсер пользовательских сессий

Официальная реализация парсинга куков и сессий, в реальном проекте также будут случаи, когда токен приваривается к URL, в это время можно использовать интерфейс HttpSessionIdResolver.

/**
 * 同时支持 sessionId 存到 cookie,header 和 request parameter
 *
 * @author songyinyin
 * @date 2020/3/18 下午 05:53
 */
@Slf4j
@Service("httpSessionIdResolver")
public class RestHttpSessionIdResolver implements HttpSessionIdResolver {

    public static final String AUTH_TOKEN = "GitsSessionID";

    private String sessionIdName = AUTH_TOKEN;

    private CookieHttpSessionIdResolver cookieHttpSessionIdResolver;

    public RestHttpSessionIdResolver() {
        initCookieHttpSessionIdResolver();
    }

    public RestHttpSessionIdResolver(String sessionIdName) {
        this.sessionIdName = sessionIdName;
        initCookieHttpSessionIdResolver();
    }

    public void initCookieHttpSessionIdResolver() {
        this.cookieHttpSessionIdResolver = new CookieHttpSessionIdResolver();
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setCookieName(this.sessionIdName);
        this.cookieHttpSessionIdResolver.setCookieSerializer(cookieSerializer);
    }


    @Override
    public List<String> resolveSessionIds(HttpServletRequest request) {
        // cookie
        List<String> cookies = cookieHttpSessionIdResolver.resolveSessionIds(request);
        if (CollUtil.isNotEmpty(cookies)) {
            return cookies;
        }
        // header
        String headerValue = request.getHeader(this.sessionIdName);
        if (StrUtil.isNotBlank(headerValue)) {
            return Collections.singletonList(headerValue);
        }
        // request parameter
        String sessionId = request.getParameter(this.sessionIdName);
        return (sessionId != null) ? Collections.singletonList(sessionId) : Collections.emptyList();
    }

    @Override
    public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
        log.info(AUTH_TOKEN + "={}", sessionId);
        response.setHeader(this.sessionIdName, sessionId);
        this.cookieHttpSessionIdResolver.setSessionId(request, response, sessionId);
    }

    @Override
    public void expireSession(HttpServletRequest request, HttpServletResponse response) {
        response.setHeader(this.sessionIdName, "");
        this.cookieHttpSessionIdResolver.setSessionId(request, response, "");
    }
}

Настроить безопасность Spring

После стольких приготовлений наконец пришло время настроить Spring Security упрощает настройку в режиме Builder.

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private DefaultUserDetailsService userDetailsService;
    /**
     * 登出成功的处理
     */
    @Autowired
    private LoginFailureHandler loginFailureHandler;
    /**
     * 登录成功的处理
     */
    @Autowired
    private LoginSuccessHandler loginSuccessHandler;
    /**
     * 登出成功的处理
     */
    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;
    /**
     * 未登录的处理
     */
    @Autowired
    private AnonymousAuthenticationEntryPoint anonymousAuthenticationEntryPoint;
    /**
     * 超时处理
     */
    @Autowired
    private InvalidSessionHandler invalidSessionHandler;
    /**
     * 顶号处理
     */
    @Autowired
    private SessionInformationExpiredHandler sessionInformationExpiredHandler;
    /**
     * 登录用户没有权限访问资源
     */
    @Autowired
    private LoginUserAccessDeniedHandler accessDeniedHandler;

    /**
     * 配置认证方式等
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * http相关的配置,包括登入登出、异常处理、会话管理等
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable();
        http.authorizeRequests()
                // 放行接口
                .antMatchers(GitsResourceServerConfiguration.AUTH_WHITELIST).permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                // 异常处理(权限拒绝、登录失效等)
                .and().exceptionHandling()
                .authenticationEntryPoint(anonymousAuthenticationEntryPoint)//匿名用户访问无权限资源时的异常处理
                .accessDeniedHandler(accessDeniedHandler)//登录用户没有权限访问资源
                // 登入
                .and().formLogin().permitAll()//允许所有用户
                .successHandler(loginSuccessHandler)//登录成功处理逻辑
                .failureHandler(loginFailureHandler)//登录失败处理逻辑
                // 登出
                .and().logout().permitAll()//允许所有用户
                .logoutSuccessHandler(logoutSuccessHandler)//登出成功处理逻辑
                .deleteCookies(RestHttpSessionIdResolver.AUTH_TOKEN)
                // 会话管理
                .and().sessionManagement().invalidSessionStrategy(invalidSessionHandler) // 超时处理
                .maximumSessions(1)//同一账号同时登录最大用户数
                .expiredSessionStrategy(sessionInformationExpiredHandler) // 顶号处理
        ;

    }

}

Аннотация @EnableWebSecurity используется для включения Spring Security, а @EnableGlobalMethodSecurity(prePostEnabled = true) — для включения @PreAuthorize. Также в комментариях к коду написаны некоторые детали, что делает его более удобным и интуитивно понятным.

После завершения настройки отправьте запросip:port/login, вы можете увидеть результат входа в систему следующим образом:

image.png

постскриптум

К этому моменту вы должны быть в состоянии настроить относительно полную структуру безопасности.Все коды в этой статье имеют открытый исходный код и протестированы.

адрес:git ee.com/songyin/…

Согласно идеям и шагам этой статьи, вы сделали первый шаг Spring Security, который дает вам общее представление обо всей структуре безопасности.Конечно, должны быть некоторые вопросы, например, почему вы не видели логин интерфейс от начала до конца? Когда я вхожу в систему, почему я перехожу кUserDetailsService#loadUserByUsername()в методе?

Пожалуйста, оставьте сообщение, чтобы рассказать о своих сомнениях, когда вы впервые вступили в контакт со SpringSecurity.


Поиск в WeChat »читать рыбалка YY», впервые читайте качественные оригинальные статьи.

Оригинальность непростая.До конца ставьте лайк этой статье, большое спасибо.