# 登录与授权

基于 Spring Security (opens new window)实现登录安全及细粒度的授权访问控制,Spring Security模型比较固定,建议通读其文档便于理解,关键控制参数已经抽取到:

com.seezoon.admin.modules.sys.security.LoginSecurityProperties

目前实现一种登录方式即账号密码登录,扩展其他登录方式也比较简单,按文档实现一个Filter。

后续考虑实现微信扫码登录,作为例子。

具体实现参考:

package com.seezoon.admin.modules.sys.security;

import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import com.seezoon.admin.modules.sys.security.handler.AdminAccessDeniedHandler;
import com.seezoon.admin.modules.sys.security.handler.AjaxAuthenticationFailureHandler;
import com.seezoon.admin.modules.sys.security.handler.AjaxAuthenticationSuccessHandler;
import com.seezoon.admin.modules.sys.security.handler.AjaxLogoutSuccessHandler;

import lombok.RequiredArgsConstructor;

/**
 * <code>
 *     账号密码登录
 *     url:/login
 *     method:POST
 *     param:
 *          username
 *          password
 *          rememberMe
 *
 *     退出登录
 *     url:/logout
 *     method:POST
 *
 * </code>
 *
 *
 * 安全配置
 *
 * @see <a>https://docs.spring.io/spring-security/site/docs/5.4.1/reference/html5</a>
 *
 *      默认的安全设置参考{@link WebSecurityConfigurerAdapter#getHttp}
 *
 * @author hdf
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@ControllerAdvice
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    public static final String[] STATIC_RESOURCES =
        {"/**/*.html", "/**/*.js", "/**/*.css", "/**/*.ico", "/**/*.png", "/**/*.jpg"};
    // public static final String[] DOC_API = {"/swagger-resources/**", "/**/api-docs"};
    private static final String DEFAULT_REMEMBER_ME_NAME = "rememberMe";
    private static final String PUBLIC_ANT_PATH = "/public/**";
    private static final String LOGIN_URL = "/login";
    private static final String LOGIN_OUT_URL = "/logout";

    private final LoginSecurityProperties loginSecurityProperties;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 需要认证和授权的请求配置
        http.authorizeRequests().antMatchers(PUBLIC_ANT_PATH).permitAll().anyRequest().authenticated();
        // 自带username filter provider 处理机制
        http.formLogin().loginProcessingUrl(LOGIN_URL).successHandler(ajaxAuthenticationSuccessHandler())
            .failureHandler(ajaxAuthenticationFailureHandler());

        // 以下为公共逻辑 如果要扩展登录方式,只需要添加类似UsernamePasswordAuthenticationFilter-> DaoAuthenticationProvider 这种整套逻辑
        // 登出处理
        http.logout().logoutUrl(LOGIN_OUT_URL).logoutSuccessHandler(ajaxLogoutSuccessHandler());
        // 默认认证过程中的异常处理,accessDeniedHandler 默认为也是返回403,spring security
        // 是在filter级别抓住异常回调handler的,所以会被全局拦截器模式@ExceptionHandler 吃掉
        http.exceptionHandling().accessDeniedHandler(new AdminAccessDeniedHandler())
            // 到认证环节的入口逻辑,默认是跳页
            .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
        // seesion 管理 一个账号登录一次,后面的挤掉前面的(spring security 默认的,true 则已登录的优先)
        // remember 采用默认解密前端remember-cookie
        http.sessionManagement().maximumSessions(loginSecurityProperties.getMaximumSessions())
            .maxSessionsPreventsLogin(loginSecurityProperties.isMaxSessionsPreventsLogin());
        http.rememberMe().rememberMeParameter(DEFAULT_REMEMBER_ME_NAME).key(loginSecurityProperties.getRememberKey())
            .useSecureCookie(true).tokenValiditySeconds((int)loginSecurityProperties.getRememberTime().toSeconds())
            .userDetailsService(adminUserDetailsService());
        // 需要添加不然spring boot 的跨域配置无效
        http.cors();
        // 安全头设置
        http.headers().defaultsDisabled()// 关闭默认
            // 浏览器根据respone content type 格式解析资源
            .contentTypeOptions()
            // xss 攻击,限制有限,还是需要通过过滤请求参数,该框架已做
            .and().xssProtection()
            // 同域名可以通过frame
            .and().frameOptions().sameOrigin()
            // CSRF 攻击 开发时候暂时disable
            // respone cookie name XSRF-TOKEN
            // requst param _csrf or below;
            // request head HEADER_NAME = "X-CSRF-TOKEN";
            // CsrfFilter 默认实现类是这个,不拦截get请求
            .and().csrf().ignoringAntMatchers(PUBLIC_ANT_PATH, LOGIN_URL)
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());// .disable();
    }

    /**
     * 忽略静态资源在HttpSecurity前面
     *
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 按需忽略
        web.ignoring().antMatchers(STATIC_RESOURCES);// .antMatchers(DOC_API);
    }

    /**
     * 防止被上层的@ExceptionHandler 范围更大的给拦截住,而无法调用accessDeniedHandler
     *
     * @param e
     */
    @ExceptionHandler(AccessDeniedException.class)
    public void accessDeniedException(AccessDeniedException e) {
        throw e;
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {

        // 默认 DaoAuthenticationProvider的userDetailsService,自定义其他登录方式还得在provider中设置
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(AdminPasswordEncoder.getEncoder());
        daoAuthenticationProvider.setUserDetailsService(adminUserDetailsService());
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        auth.authenticationProvider(daoAuthenticationProvider);

        // 只因为下面的没有暴露setHideUserNotFoundExceptions 方法,导致UsernameNotFoundException 被内部转换成BadCredentialsException
        // auth.userDetailsService(adminUserDetailsService()).passwordEncoder(AdminPasswordEncoder.getEncoder());
    }

    @Bean
    public AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler() {
        return new AjaxAuthenticationSuccessHandler();
    }

    @Bean
    public AjaxLogoutSuccessHandler ajaxLogoutSuccessHandler() {
        return new AjaxLogoutSuccessHandler();
    }

    @Bean
    public AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler() {
        return new AjaxAuthenticationFailureHandler();
    }

    @Bean
    public AdminUserDetailsServiceImpl adminUserDetailsService() {
        return new AdminUserDetailsServiceImpl();
    }
}

# CSRF & XSS

设置代码,框架多特殊字符会自动转义避免XSS 攻击,针对CSRF 登录成功后获取Respone Cookie中 name=XSRF-TOKEN,在请求时候可以统一携带param _csrf=xxx 或者Header:X-CSRF-TOKEN=xxx。

CSRF 默认不拦截get 请求。

   http.headers().defaultsDisabled()// 关闭默认
            // 浏览器根据respone content type 格式解析资源
            .contentTypeOptions()
            // xss 攻击,限制有限,还是需要通过过滤请求参数,该框架已做
            .and().xssProtection()
            // 同域名可以通过frame
            .and().frameOptions().sameOrigin()
            // CSRF 攻击 
            // respone cookie name XSRF-TOKEN
            // requst param _csrf or below;
            // request head HEADER_NAME = "X-CSRF-TOKEN";
            // CsrfFilter 默认实现类是这个,不拦截get请求
            .and().csrf().ignoringAntMatchers(PUBLIC_ANT_PATH, LOGIN_URL)
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());

# 单账号登录限制

可以配置一个账号是否可以多人登录,具体设置:

http.sessionManagement().maximumSessions(loginSecurityProperties.getMaximumSessions())
            .maxSessionsPreventsLogin(loginSecurityProperties.isMaxSessionsPreventsLogin());

需要设定HttpSessionEventPublisher,Spring session 还需指定SessionRegistry。

# RememberMe

记住我实现,可以控制记住时长,以及安全控制盐值,可以定期更换。

http.rememberMe().rememberMeParameter(DEFAULT_REMEMBER_ME_NAME).key(loginSecurityProperties.getRememberKey())
         .useSecureCookie(true).tokenValiditySeconds((int)loginSecurityProperties.getRememberTime().toSeconds())

修改密码后,RememberMe会自动失效。

# 登录安全

账号错误到一定次数会锁定,IP错误一定次数会锁定IP,见LoginSecurityProperties中配置。

# 授权

采用RBAC(Role-based access control)的授权访问控制,即基于角色的访问控制。登录时候会放入相关授权信息,主要包括可访问菜单、按钮。

package com.seezoon.admin.modules.sys.security;

import java.util.ArrayList;
import java.util.List;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import com.seezoon.admin.framework.file.FileService;
import com.seezoon.admin.modules.sys.dto.UserInfo;
import com.seezoon.admin.modules.sys.security.constant.LockType;
import com.seezoon.admin.modules.sys.service.SysUserService;
import com.seezoon.dao.framework.constants.EntityStatus;
import com.seezoon.dao.modules.sys.entity.SysMenu;
import com.seezoon.dao.modules.sys.entity.SysRole;
import com.seezoon.dao.modules.sys.entity.SysUser;
import com.seezoon.framework.utils.IpUtil;

/**
 * 用户加载逻辑
 *
 * @author hdf
 */
public class AdminUserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;
    @Autowired
    private SysUserService sysUserService;
    @Autowired
    private FileService fileService;
    @Autowired
    private LoginSecurityService loginSecurityService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        HttpServletRequest request =
            ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
        String remoteIp = IpUtil.getRemoteIp(request);
        boolean ipLocked = loginSecurityService.getIpLockStrategy().isLocked(remoteIp);
        if (ipLocked) {
            throw new LockedException(LockType.IP.name());
        }

        if (StringUtils.isBlank(username)) {
            throw new UsernameNotFoundException("username is empty");
        }

        boolean locked = loginSecurityService.getUsernameLockStrategy().isLocked(username);
        if (locked) {
            throw new LockedException(LockType.USERNAME.name());
        }
        SysUser user = sysUserService.findByUsername(username);
        if (null == user) {
            throw new UsernameNotFoundException(username + "  not found");
        }

        if (EntityStatus.INVALID.status() == user.getStatus()) {
            throw new DisabledException(username + " disabled");
        }

        UserInfo userInfo = new UserInfo(user.getId(), user.getDeptId(), user.getUsername(), user.getName());
        userInfo.setDeptName(user.getDeptName());
        userInfo.setPhoto(fileService.getUrl(user.getPhoto()));
        // 角色及权限信息登录成功后放入
        AdminUserDetails adminUserDetails = new AdminUserDetails(userInfo, username, user.getPassword());
        adminUserDetails.setAuthorities(getAuthorities(user.getId()));
        return adminUserDetails;
    }

    /**
     *
     * @return
     */
    private List<GrantedAuthority> getAuthorities(Integer userId) {
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 角色处理
        List<SysRole> userRoles = userService.findRolesByUserId(userId);
        userRoles.stream().filter(v -> StringUtils.isNotBlank(v.getCode())).forEach(v -> {
            authorities.add(new UserGrantedAuthority(v.getCode(), true));
        });
        List<SysMenu> userMenus = userService.findMenusByUserId(userId);
        userMenus.stream().filter(v -> StringUtils.isNotBlank(v.getPermission())).forEach(v -> {
            authorities.add(new UserGrantedAuthority(v.getPermission()));
        });
        return authorities;
    }
}

# 细粒度控制

Spring Security 提供了多种方式,Authorization Architecture (opens new window)

这里列举常用的使用方式基本够用,更多表达式语法Expression-Based Access Control (opens new window)

// 只看UserDetails中Authorities是否包含即可
@PreAuthorize("hasAuthority('ROLE_admin')")
// 判断有ROLE_ 前缀的Authority
@PreAuthorize("hasRole('admin')") 
// 不建议使用, 上面的够用了
@Secured({"ROLE_admin"}) 

如果是角色判断需要GrantedAuthority放入时候需要加上ROLE_前缀,该框架已处理。

# 动态菜单&按钮

根据用户的角色,获取用户菜单和按钮权限。