# security-launch
**Repository Path**: yanglf_admin/security-launch
## Basic Information
- **Project Name**: security-launch
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2021-10-02
- **Last Updated**: 2022-02-11
## Categories & Tags
**Categories**: Uncategorized
**Tags**: SpringBoot
## README
# 核心功能
- Authentication: 身份认证,用户登录的验证
- Authorization: 访问授权,授权资源的访问权限
- 安全防护,防止跨站请求,session 攻击等
# 环境准备
https://gitee.com/hanxt/boot-security-starter
## 添加依赖
```xml
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-security
com.baomidou
mybatis-plus-boot-starter
3.3.2
mysql
mysql-connector-java
org.springframework.boot
spring-boot-starter-freemarker
org.projectlombok
lombok
org.springframework.boot
spring-boot-devtools
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
```
## 修改配置文件
```yaml
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
datasource:
url: jdbc:mysql://localhost:3306/bootdb?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: admin123
driver-class-name: com.mysql.cj.jdbc.Driver
thymeleaf:
cache: false
suffix: .ftlh
prefix: classpath:/templats/
security:
user:
name: admin
password: admin123
mybatis-plus:
mapper-locations: classpath:mapper/*Mapper.xml
logging:
level:
com.yanglf.demo6: debug
```
# Http Basic
## 流程
1. 客户端请求服务器资源
2. Basic Authentication Filter 过滤器 拦截 需要输入用户名、密码
3. 用户输入用户名,密码(base64加密) 再请求服务器
4. 登录成功后,客户端每次 header 都要携带 Authorization (Basic ),这个字符串也就是 对 `userName:password` 的 base64加密,不安全
## 集成步骤
> 新增 security 配置文件
```java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 开启 http basic
http.httpBasic()
.and()
// 所有请求需要登录才可以访问
.authorizeRequests()
.anyRequest()
.authenticated();
}
}
```
# 密码加密校验
## Hash算法
- 单项算法
- hash值(密码)不可逆
## PasswordEncoder
- `encoder(rawPassword)` 把参数按照特定的解析规则进行解析。
- `matches(rawPassword,encodedPassword)` 验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回 true;如果不匹配,则返回 false。第一个参数表示需要被解析的密码。第二个参数表示存储的密码
- `upgradeEncoding(encodedPassword)` 如果解析的密码能够再次进行解析且达到更安全的结果则返回 true,否则返回 false。默认返回 false。
> 密码组成部分 $2a$10$iYDIDVSGnr06FjmbNRoyUuvbxlS2eL9hF9eOCGUoRmUXzUfS9NyLO
- `$2a` 表示 Bcrypt 算法版本
- `$10` 表示算法强度
- `$iYDIDVSGnr06FjmbNRoyUu` 随机生成的盐值
- `vbxlS2eL9hF9eOCGUoRmUXzUfS9NyLO` hash值
# FormLogin 认证模式
- 不需要写 controller
- 默认集成 `UsernamePasswordAuthencationFilter`,只需要针对它进行配置
> 修改配置文件
```java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 权限配置 从上往下配置 anyRequest().authenticated() 只能配置到最后
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
// formLogin
.formLogin()
// 登录页面配置
.loginPage("/login.html")
// 处理登录业务逻辑的接口url
.loginProcessingUrl("/login")
// 登录 用户名 请求参数名
.usernameParameter("userName")
// 登录 密码 请求参数名
.passwordParameter("password")
// 登录成功跳转的url地址
.defaultSuccessUrl("/")
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
// user 和 admin 角色可以访问的资源
.antMatchers("/", "/biz1", "biz2")
// hasAnyAuthority("ROLE_user") 等价与 hasAnyRole("user")
.hasAnyAuthority("ROLE_user", "ROLE_admin")
// admin 可以访问的资源
.antMatchers("/syslog", "/sysuser")
.hasAnyRole("admin")
// 其他请求都需要认证
.anyRequest()
.authenticated();
}
// 配置用户方式一
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("123456"))
.roles("user")
.and()
.withUser("admin")
.password(passwordEncoder().encode("admin123"))
.roles("admin")
.and()
.passwordEncoder(passwordEncoder());
}
// 配置用户方式二
/* @Override
@Bean
protected UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager=new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("javaboy")
.password(passwordEncoder().encode("admin123"))
.build());
manager.createUser(User.withUsername("yanglf")
.password(passwordEncoder().encode("123456"))
.build());
return manager;
}*/
// 角色继承, admin 继承 user 的权限 ,user 可以访问的权限,admin 都可以访问
// 一般 用作 上级 继承 下级的 权限
@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl hierarchy=new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_admin > ROLE_user");
return hierarchy;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
// 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要
web.ignoring().antMatchers("/css/**","/fonts","/img/**","/js/**");
}
}
```
> classpath: static 下边创建 login.html
```html
系统登录
```
# 登录认证流程
**登录认证主体: Authentication**
1. SecurityContent
2. PersistenceFilter
3. BasicAuthenticationFilter
4. UsernamePasswordAuthenticationFilter
5. RememberMeAuthenticationFilter
6. SocialAuthenticationFilter
7. Oauth2AuthenticationProcessingFilter
8. Oauth2ClientAuthenticationProcessingFilter
9. ExceptionTranslationFilter
10. FilterSecurityInterceptor
11. Controller API
# 自定义登录
## 自定义登录验证处理结果
### 场景
- 不同的人登录后,看到不同的首页
- 前后端分离,期望返回JSON,而不是 HTML 页面
### 登录成功处理逻辑
```java
@Component
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Value("${spring.security.loginType}")
private String loginType;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
if (loginType.equalsIgnoreCase("json")) {
response.setContentType("application/json;charset=UTF-8");
AjaxResponse ajaxResponse = AjaxResponse.success("登录成功");
response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse));
} else {
// 会自动跳转到登录之前请求的页面
super.onAuthenticationSuccess(request, response, authentication);
}
}
}
```
### 登录失败逻辑
```java
@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Value("${spring.security.loginType}")
private String loginType;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
if (loginType.equalsIgnoreCase("JSON")) {
response.setContentType("application/json;charset=UTF-8");
AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, "用户名或密码错误,请检查后重新登录");
response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse));
} else {
response.setContentType("application/html;charset=UTF-8");
super.onAuthenticationFailure(request, response, exception);
}
}
}
```
### 权限缺失
```java
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR,"访问权限缺失");
response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse));
}
}
```
### 未登录
```java
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable
{
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException
{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},尚未登录,请登录", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
```
### 修改配置文件
```java
// 登录成功
@Autowired
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
// 登录失败
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
//认证失败处理类
@Autowired
private MyAuthenticationEntryPoint unauthorizedHandler;
// 403 无权限访问处理
@Autowired
private MyAccessDeniedHandler myAccessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.exceptionHandling()
.accessDeniedHandler(myAccessDeniedHandler)
.authenticationEntryPoint(unauthorizedHandler)
// formLogin
.formLogin()
// 登录页面配置
.loginPage("/login.html")
// 处理登录业务逻辑的接口url
.loginProcessingUrl("/login")
// 登录 用户名 请求参数名
.usernameParameter("username")
// 登录 密码 请求参数名
.passwordParameter("password")
.successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailureHandler)
// 登录成功跳转的url地址
//.defaultSuccessUrl("/")
// 登录失败跳转的url地址
//.failureUrl("/login.html")
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
// user 和 admin 角色可以访问的资源
.antMatchers("/", "/biz1", "biz2")
// hasAnyAuthority("role_user") 等价与 hasAnyRole("user")
.hasAnyAuthority("ROLE_user", "ROLE_admin")
// admin 可以访问的资源
.antMatchers("/syslog", "/sysuser")
.hasAnyRole("admin")
.anyRequest()
.authenticated();
}
```
### 修改前端页面
```html
系统登录
```
## 图形验证码登录
### 验证码生成
#### 新增依赖
```xml
com.github.penggle
kaptcha
2.3.2
javax.servlet-api
javax.servlet
org.apache.commons
commons-lang3
3.9
```
#### 配置文件
需要修改 setting - file encoding 的编码 为 UTF-8
```properties
kaptcha.border=no
kaptcha.border.color=105,179,90
kaptcha.image.width=100
kaptcha.image.height=45
kaptcha.session.key=code
kaptcha.textproducer.font.color=blue
kaptcha.textproducer.fint.size=35
kaptcha.textproducer.char.length=4
kaptcha.textproducer.font.names=宋体,楷体,微软雅黑
```
#### 配置类
```java
@Configuration
@PropertySource("classpath:kaptcha.properties")
public class KaptchaConfig {
@Value("${kaptcha.border}")
private String border;
@Value("${kaptcha.border.color}")
private String borderColor;
@Value("${kaptcha.textproducer.font.color}")
private String fontColor;
@Value("${kaptcha.textproducer.fint.size}")
private String fontSize;
@Value("${kaptcha.image.width}")
private String imageWidth;
@Value("${kaptcha.image.height}")
private String imageHeight;
@Value("${kaptcha.session.key}")
private String sessionKey;
@Value("${kaptcha.textproducer.char.length}")
private String charLength;
@Value("${kaptcha.textproducer.font.names}")
private String fontNames;
@Bean(name = "capachaProducer")
public DefaultKaptcha getKaptcha() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border", border);
properties.setProperty("kaptcha.border.color", borderColor);
properties.setProperty("kaptcha.textproducer.font.color", fontColor);
properties.setProperty("kaptcha.textproducer.fint.size", fontSize);
properties.setProperty("kaptcha.image.width", imageWidth);
properties.setProperty("kaptcha.image.height", imageHeight);
properties.setProperty("kaptcha.session.key", sessionKey);
properties.setProperty("kaptcha.textproducer.char.length", charLength);
properties.setProperty("kaptcha.textproducer.font.names", fontNames);
defaultKaptcha.setConfig(new Config(properties));
return defaultKaptcha;
}
}
```
#### 验证码对象
```java
@Data
public class CaptchaCode {
private String code;
private LocalDateTime expireTime;
public CaptchaCode(String code, int expireAfterSecond) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSecond);
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expireTime);
}
}
```
#### 生成验证码接口
```java
@RestController
public class CaptchaContoller {
@Autowired
private DefaultKaptcha captchaProducer;
@GetMapping("/kaptcha")
public void kaptcha(HttpSession session,
HttpServletResponse response) throws IOException {
response.setHeader("Expires", "0");
response.setHeader("Cache-Control", "no-store,no-cache,must-revalidate");
response.setHeader("Cache-Control", "post-check=0,pre-check=0");
response.setHeader("Pragma", "no-cache");
response.setContentType("image/jpeg");
String capText = captchaProducer.createText();
session.setAttribute("captcha_key", new CaptchaCode(capText, 2 * 60));
try (ServletOutputStream out = response.getOutputStream()) {
BufferedImage bufferedImage = captchaProducer.createImage(capText);
ImageIO.write(bufferedImage, "jpg", out);
out.flush();
}
}
}
```
#### 配置类放行获取验证码接口
```java
//SecurityConfig
...
.antMatchers("/login.html", "/login", "/kaptcha").permitAll()
```
#### 修改前端接口
```html
```
### 图形验证码过滤器
- 编写自定义图形验证码过滤器 `CaptchaCodeFilter` ,过滤器中拦截登录请求
- 过滤器中从session获取验证码文字和用户输入的对比,对比通过执行其他过滤链
- 对比不通过,抛出 `SessionAuthenticationException` 异常,交给 AuthenticationFailureHandler 处理
- 最后将 CaptchaCodeFilter 放在 UsernamePasswordAuthenticationFilter 过滤器前执行
> 校验验证码过滤器
```java
@Component
public class CaptchaCodeFilter extends OncePerRequestFilter {
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
String method = request.getMethod();
if (StringUtils.equals(requestURI,"/login") && StringUtils.equalsIgnoreCase(method,"post")) {
// 验证码是否一致
try {
validate(new ServletWebRequest(request));
}catch (AuthenticationException e){
myAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
return;
}
}
filterChain.doFilter(request, response);
}
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
HttpSession session = request.getRequest().getSession();
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "captchaCode");
if (StringUtils.isEmpty(codeInRequest)) {
throw new SessionAuthenticationException("验证码不能为空");
}
CaptchaCode codeInSession = (CaptchaCode) session.getAttribute("captcha_key");
if (Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("验证码不存在");
}
if(codeInSession.isExpired()){
session.removeAttribute("captcha_key");
throw new SessionAuthenticationException("验证码已过期");
}
if(!StringUtils.equals(codeInSession.getCode(),codeInRequest)){
throw new SessionAuthenticationException("验证码不匹配");
}
}
}
```
> 修改校验失败逻辑
```java
@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Value("${spring.security.loginType}")
private String loginType;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String errorMsg = "用户名或密码错误,请检查后重新登录";
if (exception instanceof SessionAuthenticationException) {
errorMsg = exception.getMessage();
}
if (loginType.equalsIgnoreCase("JSON")) {
response.setContentType("application/json;charset=UTF-8");
AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, errorMsg);
response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse));
} else {
response.setContentType("application/html;charset=UTF-8");
super.onAuthenticationFailure(request, response, exception);
}
}
}
```
> 将校验过滤器配置到 `UsernamePasswordAuthenticationFilter` 之前
```java
@Autowired
private CaptchaCodeFilter captchaCodeFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
// formLogin
.formLogin()
// 登录页面配置
.loginPage("/login.html")
// 处理登录业务逻辑的接口url
.loginProcessingUrl("/login")
// 登录 用户名 请求参数名
.usernameParameter("username")
// 登录 密码 请求参数名
.passwordParameter("password")
.successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailureHandler)
// 登录成功跳转的url地址
//.defaultSuccessUrl("/")
// 登录失败跳转的url地址
//.failureUrl("/login.html")
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login", "/kaptcha").permitAll()
// user 和 admin 角色可以访问的资源
//.antMatchers("/", "/biz1", "biz2")
/* .antMatchers("/").hasAnyAuthority("/")
.antMatchers("/biz1").hasAnyAuthority("/biz1")
.antMatchers("/biz2").hasAnyAuthority("/biz2")
// hasAnyAuthority("role_user") 等价与 hasAnyRole("user")
// admin 可以访问的资源
.antMatchers("/sysuser").hasAnyAuthority("/sysuser")
.antMatchers("/syslog").hasAnyAuthority("/syslog")*/
.anyRequest().access("@rbaService.hasPermission(request,authentication)")
.and().rememberMe()
.rememberMeParameter("remember-me-new")
.rememberMeCookieName("remember-me-cookie")
.tokenValiditySeconds(2 * 24 * 60 * 60)
.tokenRepository(persistentTokenRepository())
// .antMatchers("/syslog", "/sysuser")
//.anyRequest()
//.authenticated()
.and()
.logout()
.logoutUrl("/logout")
//.logoutSuccessUrl("/login.html")
.logoutSuccessHandler(myLogoutSuccessHandler)
.deleteCookies("JESSION")
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1)
// true 表示已经登录,就不允许再登陆
// false 表示允许再次登录,但是之前登录的账号会被踢下线
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(myExpiredSessionStrategy);
}
```
## 短信验证码登录
- `SmsController` 获取短信验证码
- `SmsCodeValidateFilter` 校验 session 中验证码是否和用户输入验证码一致
- `SmsCodeAuthenticationFilter` 校验验证码成功,进行登录授权,调用 `UserDetailsService`
### 获取短信验证码
> 获取验证码接口
```java
@Slf4j
@RestController
public class SmsController {
@Autowired
private MyUserDetailsServiceMapper myUserDetailsServiceMapper;
@GetMapping("/smscode")
public AjaxResponse sms(@RequestParam String mobile,
HttpSession httpSession) {
MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(mobile);
if (Objects.isNull(myUserDetails)) {
return AjaxResponse.error(
CustomExceptionType.USER_INPUT_ERROR,
"您输入的手机号未曾注册");
}
SmsCode smsCode = new SmsCode(
RandomStringUtils.randomNumeric(4), 60, mobile);
// TODO 调用短信接口发送短信
log.info(smsCode.getCode() + "+>" + mobile);
httpSession.setAttribute("sms_key", smsCode);
return AjaxResponse.success("短信验证码发送成功:【" + smsCode.getCode() + "】");
}
}
```
> 验证码对象
```java
@Data
public class SmsCode {
private String code;
private String mobile;
private LocalDateTime expireTime;
public SmsCode(String code, int expireAfterSecond, String mobile) {
this.code = code;
this.mobile = mobile;
this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSecond);
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expireTime);
}
}
```
> 放行获取验证码接口
```java
//SecurityConfig
...
.antMatchers("/login.html", "/login", "/kaptcha","/smscode").permitAll()
```
> 修改前端页面
```html
短信登录
```
### 验证短信验证码
#### 验证逻辑
```java
@Component
public class SmsCodeValidateFilter extends OncePerRequestFilter {
@Autowired
private MyUserDetailsServiceMapper myUserDetailsServiceMapper;
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
String method = request.getMethod();
if (requestURI.equals("/smslogin") && method.equalsIgnoreCase("post")) {
// 验证码是否一致
try {
validate(new ServletWebRequest(request));
} catch (AuthenticationException e) {
myAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
HttpSession session = request.getRequest().getSession();
String mobileInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "mobile");
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode");
SmsCode codeInSession = (SmsCode) session.getAttribute("sms_key");
if (StringUtils.isEmpty(mobileInRequest)) {
throw new SessionAuthenticationException("手机号不能为空");
}
if (StringUtils.isEmpty(codeInRequest)) {
throw new SessionAuthenticationException("短信验证码不能为空");
}
if (Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("短信验证码不存在");
}
if (codeInSession.isExpired()) {
session.removeAttribute("sms_key");
throw new SessionAuthenticationException("短信验证码已过期");
}
if (!codeInSession.getCode().equals(codeInRequest)) {
throw new SessionAuthenticationException("验证码不匹配");
}
if (!codeInSession.getMobile().equals(mobileInRequest)) {
throw new SessionAuthenticationException("短信发送目标与您的手机号不一致");
}
MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(mobileInRequest);
if(Objects.isNull(myUserDetails)){
throw new SessionAuthenticationException("手机号未曾注册");
}
}
}
```
#### Security配置类放行登录url
```java
.antMatchers("/login.html", "/login", "/kaptcha","/smscode","/smslogin").permitAll()
```
#### 修改前端页面
```html
短信登录
```
### 登录认证过滤器
**用户密码登录**
- UsernamePasswordAuthenticationFilter
- AuthenticationManager
- DaoAuthenticationProvider
- UserDetailsService
**短信验证码登录**
- SmsCodeAuthenticationFilter
- AuthenticationManager
- SmsCodeAuthenticationProvider
- UserDetailsService
>`SmsCodeAuthenticationFilter`
```java
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile"; private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY; private boolean postOnly = true; public SmsCodeAuthenticationFilter() { super(new AntPathRequestMatcher("/smslogin", "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String mobile = this.obtainMobile(request); if (mobile == null) { mobile = ""; } mobile = mobile.trim(); SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } } @Nullable protected String obtainMobile(HttpServletRequest request) { return request.getParameter(this.mobileParameter); } protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } public void setMobileParameter(String mobileParameter) { Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null"); this.mobileParameter = mobileParameter; } public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } public final String getMobileParameter() { return this.mobileParameter; }}
```
>`SmsCodeAuthenticationToken`
```java
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 530L; // 存放认证信息,认证之前放的手机号,认证之后存放 UserDetails private final Object principal; public SmsCodeAuthenticationToken(Object principal) { super((Collection) null); this.principal = principal; this.setAuthenticated(false); } public SmsCodeAuthenticationToken(Object principal, Collection extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); } @Override public Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.principal; } @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } else { super.setAuthenticated(false); } } @Override public void eraseCredentials() { super.eraseCredentials(); }}
```
> `SmsCodeAuthenticationProvider`
```java
public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } protected UserDetailsService getUserDetailsService() { return this.userDetailsService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication; UserDetails userDetails = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal()); if (userDetails == null) { throw new InternalAuthenticationServiceException("无法根据手机号获取用户信息"); } SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities()); authenticationResult.setDetails(authenticationToken.getDetails()); return authenticationResult; } @Override public boolean supports(Class> authentication) { return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); }}
```
### 配置过滤器
> `SmsCodeSecurityConfig`
```java
@Componentpublic class SmsCodeSecurityConfig extends SecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private MyExpiredSessionStrategy myExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private SmsCodeValidateFilter smsCodeValidateFilter; @Override public void configure(HttpSecurity http) throws Exception { SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter(); smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); smsCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider(); smsCodeAuthenticationProvider.setUserDetailsService(myUserDetailsService); http.addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); http.authenticationProvider(smsCodeAuthenticationProvider) .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }}
```
> 子配置类添加到父配置类
```java
@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private MyExpiredSessionStrategy myExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private MyLogoutSuccessHandler myLogoutSuccessHandler; @Autowired private CaptchaCodeFilter captchaCodeFilter; @Autowired private SmsCodeSecurityConfig smsCodeSecurityConfig; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) .and() .apply(smsCodeSecurityConfig) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login", "/kaptcha", "/smscode", "/smslogin").permitAll() // user 和 admin 角色可以访问的资源 //.antMatchers("/", "/biz1", "biz2") /* .antMatchers("/").hasAnyAuthority("/") .antMatchers("/biz1").hasAnyAuthority("/biz1") .antMatchers("/biz2").hasAnyAuthority("/biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") // admin 可以访问的资源 .antMatchers("/sysuser").hasAnyAuthority("/sysuser") .antMatchers("/syslog").hasAnyAuthority("/syslog")*/ .anyRequest().access("@rbaService.hasPermission(request,authentication)") .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) // .antMatchers("/syslog", "/sysuser") //.anyRequest() //.authenticated() .and() .logout() .logoutUrl("/logout") //.logoutSuccessUrl("/login.html") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(myExpiredSessionStrategy); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService) // 验证密码时候匹配 BCryptPasswordEncoder 加密 .passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(WebSecurity web) throws Exception { // 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要 web.ignoring().antMatchers("/css/**", "/fonts", "/img/**", "/js/**"); } @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; }}
```
### 修改查询条件
> UserDetailsService
```java
@Componentpublic class MyUserDetailsService implements UserDetailsService { @Autowired private MyUserDetailsServiceMapper myUserDetailsServiceMapper; @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { // 用户基础数据加载 MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(userName); if (Objects.isNull(myUserDetails)) { throw new UsernameNotFoundException("用户名不存在"); } // 用户角色加载 List roleCodes = myUserDetailsServiceMapper.findRoleByUserName(userName); // 根据角色列表加载当前用户所有权限 List authorities = myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes); roleCodes = roleCodes.stream() .map(rc -> "ROLE_" + rc) .collect(Collectors.toList()); authorities.addAll(roleCodes); myUserDetails.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList( String.join(",", authorities) )); return myUserDetails; }}
```
>MyUserDetailsServiceMapper
```java
public interface MyUserDetailsServiceMapper extends BaseMapper {
// 根据用户id查询用户信息
@Select("SELECT username,password,enabled \n" +
"FROM sys_user u \n" +
"WHERE u.username = #{userId} OR u.phone = #{userId}")
MyUserDetails findByUserName(@Param(value = "userId") String userId);
// 根据用户id查询用户角色
@Select("SELECT role_code \n" +
"FROM sys_role r \n" +
"LEFT JOIN sys_user_role ur ON ur.role_id = r.id \n" +
"LEFT JOIN sys_user u ON u.id = ur.user_id \n" +
"WHERE u.username = #{userId} OR u.phone = #{userId}")
List findRoleByUserName(@Param(value = "userId") String userId);
// 根据用户角色查询用户权限
@Select({
""
})
List findAuthorityByRoleCodes(@Param(value = "roleCodes") List roleCodes);
@Select("SELECT url \n" +
"FROM sys_menu m \n" +
"LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id \n" +
"LEFT JOIN sys_role r ON r.id = rm.role_id \n" +
"LEFT JOIN sys_user_role ur ON ur.role_id = r.id \n" +
"LEFT JOIN sys_user u ON u.id=ur.user_id \n" +
"WHERE u.user_name = #{userId} OR u.phone = #{userId}")
List findUrlsByUserName(@Param(value = "userId") String userId);
}
```
# Session 会话管理
## Spring Security session 创建策略
- `always` 如果当前请求没有对应的session存在,创建一个session
- `ifRequired` 默认,在需要使用到session时创建session
- `never` Spring Security 将永远不会主动创建session,但如果session在当前应用中已经存在,它将使用该session
- `stateless` Spring Security 不会创建或使用任何session,适合于接口型的无状态应用(前后端分离),该方式节省内存资源
```java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
```
## 会话超时管理
- `server.servlet.session.timeout=15m`
- `spring.session.timeout=15m`
```java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
// 非法 session
.invalidSessionUrl("/sessionExpired.html");
}
```
## session 保护
- `migrationSession` 默认保护方式,即对于同一个session用户,每次登录验证将创建一个新的HTTP session,旧的HTTP session将无效,将旧的session属性值复制到新session上面
- `none` 原始会话不会失效
- `newSession` 将创建一个干净的会话,而不会复制旧会话中的任何实行
```java
@Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement() .sessionFixation() .migrateSession(); }
```
## cookie 的安全
- `httpOnly` 如果为true,则浏览器脚本将无法访问cookie
- `secure` 如果为true,则仅通过 HTTPS 连接发送 cookie,HTTP 无法携带 cookie
```properties
server.servlet.session.cookie.http-only=falseserver.servlet.session.cookie.secrue=true
```
## 同账号多端登录踢下线
### 限制最大登录用户数量
> 配置 最大session 数量
```java
@Autowired
private CustomExpiredSessionStrategy customExpiredSessionStrategy;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
// formLogin
.formLogin()
// 登录页面配置
.loginPage("/login.html")
// 处理登录业务逻辑的接口url
.loginProcessingUrl("/login")
// 登录 用户名 请求参数名
.usernameParameter("username")
// 登录 密码 请求参数名
.passwordParameter("password")
.successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailureHandler)
// 登录成功跳转的url地址
//.defaultSuccessUrl("/")
// 登录失败跳转的url地址
//.failureUrl("/login.html")
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
// user 和 admin 角色可以访问的资源
.antMatchers("/", "/biz1", "biz2")
// hasAnyAuthority("role_user") 等价与 hasAnyRole("user")
.hasAnyAuthority("ROLE_user", "ROLE_admin")
// admin 可以访问的资源
.antMatchers("/syslog", "/sysuser")
.hasAnyRole("admin")
.anyRequest()
.authenticated()
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1)
// true 表示已经登录,就不允许再登陆
// false 表示允许再次登录,但是之前登录的账号会被踢下线
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(customExpiredSessionStrategy);
}
```
> 踢下线逻辑
```java
@Component
public class CustomExpiredSessionStrategy implements SessionInformationExpiredStrategy {
@Value("${spring.security.loginType}")
private String loginType;
// 页面跳转处理逻辑
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException {
HttpServletRequest request = sessionInformationExpiredEvent.getRequest();
HttpServletResponse response = sessionInformationExpiredEvent.getResponse();
if (loginType.equalsIgnoreCase("JSON")) {
Date lastRequest = sessionInformationExpiredEvent.getSessionInformation().getLastRequest();
String message = "您的账号登录已经超时或者已经在另一台机器登录,您被迫下线。" + lastRequest;
AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, message);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse));
} else {
redirectStrategy.sendRedirect(request, response, "/login.html");
}
}
}
```
# 集成数据库
## 默认JdbcUserDetailsManager
### 创建表
security 定义 `user.ddl`
```sql
create table users(username varchar(50) not null primary key,password varchar(500) not null,enabled boolean not null);
create table authorities (username varchar(50) not null,authority varchar(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
```
### 添加有依赖
```xml
mysql
mysql-connector-java
org.springframework.boot
spring-boot-starter-jdbc
```
### 配置数据源
```yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/bootdb?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: admin123
driver-class-name: com.mysql.cj.jdbc.Driver
```
### 配置用户
```java
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
protected UserDetailsService userDetailsService() {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager();
if(!manager.userExists("javajob")){
manager.createUser(User.withUsername("javaboy")
.password(passwordEncoder().encode("admin123"))
.build());
}
if(!manager.userExists("yanglf")){
manager.createUser(User.withUsername("yanglf")
.password(passwordEncoder().encode("admin123"))
.build());
}
return manager;
}
```
## JPA 动态加载用户
### 新增依赖
```xml
org.springframework.boot
spring-boot-starter-data-jpa
mysql
mysql-connector-java
```
### 新增配置
```yaml
spring:
jpa:
# 使用 innodb 数据库 支持事务
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
hibernate:
# create 每次都会删除上一次的表,根据定义的 model 重新创建
# create-drop 每次 session 关闭 删除表,创建 session 创建表
# update 数据库没有创建,数据库存在,更新表结构,不会删除以前的数据
# validate 每次加载时候,验证数据库结构和定义的model是否一致,不一致报错,比较安全
ddl-auto: update
database: mysql
show-sql: true
```
### 新建model
> Role
```java
@Data
@Entity(name="t_role")
public class Role{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String nameZh;
}
```
> User
```java
@Data
@Entity(name="t_user")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 密码
private String password;
// 用户名
private String username;
// 角色
// 多对多关系
@ManyToMany(fetch=FetchType.EAGER,cascade=CascadeType.PERSIST)
private List roles;
// 账号是否没过期
private boolean accountNoneExpired;
// 账号是否没锁定
private boolean accountNoneLocked;
// 密码是否没过期
private boolean credentialsNoneExpired;
// 账号是否可用
private boolean enabled;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
List authorities=new ArrayList<>();
for (Role role : getRoles){
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
```
### 新建 mapper 和 service
> mapper
```java
public interface UserDao extends JpaRepository{
User findUserByUsername(String username);
}
```
> service
```java
@Component
public class UserService implements UserDetailsService {
@Autowired
private UserDao userDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 用户基础数据加载
User user = userDao.findUserByUsername(username);
if (Objects.isNull(user)) {
throw new UsernameNotFoundException("用户名不存在");
}
return user;
}
}
```
### 修改配置类
```java
@Autowired
private UserService userService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService)
// 验证密码时候匹配 BCryptPasswordEncoder 加密
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
```
## RBAC 角色权限管理控制模型
### Role-Base Access Control
- 用户: 系统接口及功能访问的操作者
- 权限: 能够访问某接口或者做某操作的授权资格
- 角色: 具有一类相同操作权限的用户的总称
> 一个用户多个角色 (菜单权限和接口权限)
- 用户表 sys_user
| 字段 | 类型 | 描述 |
| ----------- | -------------- | -------------------------------- |
| id (PK) | bigint(20) | id |
| username | varchar(64) | 用户名 |
| password | varchar(64) | 密码 |
| org_id | bigint(20) | 组织id |
| enabled | tinyint(1) | 是否有效(0-无效用户,1-有效用户) |
| phone | varchar(16) | 手机号 |
| email | varchar(32) | 邮箱 |
| create_time | datetime | 创建时间 |
| ~~role_id~~ | ~~bigint(20)~~ | ~~角色id(一个用户对一个角色)~~ |
- 角色表 sys_role
| 字段 | 类型 | 说明 |
| --------- | ------------ | ------------------------ |
| id (PK) | bigint(20) | id |
| role_name | varchar(32) | 角色名称(中文) |
| role_desc | varchar(128) | 角色描述 |
| role_code | varchar(32) | 角色编码( ADMIN、COMMON) |
| sort | int(11) | 排序 |
| status | tinyint(1) | 是否禁用(0-否,1-是) |
- 用户角色表 sys_user_role
| 字段 | 类型 | 说明 |
| ------- | ---------- | ------ |
| user_id | bigint(20) | 用户id |
| role_id | bigint(20) | 角色id |
- 菜单表 sys_menu
| 字段 | 类型 | 说明 |
| --------- | ------------ | ----------------------- |
| id (PK) | bigint(20) | id |
| menu_pid | bigint(20) | 父菜单id |
| menu_pids | varchar(128) | 当前菜单所有父菜单 |
| is_leaf | tinyint(1) | 是否叶子节点(0-否,1-是) |
| menu_name | varchar(64) | 菜单名称 |
| url | varchar(64) | 菜单跳转url |
| icon | varchar(45) | 菜单图标 |
| sort | int(11) | 排序 |
| level | int(11) | 菜单层级 |
| status | tinyint(1) | 是否禁用(0-否,1-是) |
- 角色菜单 sys_role_menu
| 字段 | 类型 | 说明 |
| ------- | ---------- | ------ |
| role_id | bigint(20) | 角色id |
| menu_id | bigint(20) | 菜单id |
- 接口表 sys_api
| 字段 | 类型 | 说明 |
| -------- | ----------- | -------- |
| id (PK) | bigint(20) | id |
| api_name | varchar(64) | api 名称 |
| url | varchar(64) | 接口url |
- 角色接口表 sys_role_api
| 字段 | 类型 | 说明 |
| ------- | ---------- | ------ |
| role_id | bigint(20) | 角色id |
| api_id | bigint(20) | 接口id |
- 组织机构表 sys_org
| 字段 | 类型 | 说明 |
| -------- | ------------ | ------------------------------------------------------- |
| id (PK) | bigint(20) | id |
| org_pid | bigint(20) | 上级组织id |
| org_pids | varchar(128) | 所有父节点id `[1],[2],[21]` 逗号隔开 `FIND_IN_SET` |
| is_leaf | tinyint(1) | 是否叶子节点(0-否,1-是) |
| org_name | varchar(32) | 组织名称 |
| address | varchar(64) | 地址 |
| phone | varchar(16) | 电话 |
| email | varchar(32) | 邮箱 |
| sort | int(11) | 排序 |
| level | int(11) | 组织层级 |
| status | tinyint(1) | 是否禁用(0-否,1-是) |
> 建表sql
```sql
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_api
-- ----------------------------
DROP TABLE IF EXISTS `sys_api`;
CREATE TABLE `sys_api` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`api_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'api 名称',
`url` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '接口url',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_api
-- ----------------------------
-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`menu_pid` bigint(20) NULL DEFAULT NULL COMMENT '父菜单id',
`menu_pids` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '当前菜单所有父菜单',
`is_leaf` tinyint(1) NULL DEFAULT NULL COMMENT '是否叶子节点(0-否,1-是)',
`menu_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单名称',
`url` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单跳转url',
`icon` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单图标',
`sort` int(11) NULL DEFAULT NULL COMMENT '排序',
`level` int(11) NULL DEFAULT NULL COMMENT '菜单层级',
`status` tinyint(1) NULL DEFAULT NULL COMMENT '是否禁用(0-否,1-是)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES (1, 0, '[0]', 0, '首页', '/', NULL, 1, 1, 0);
INSERT INTO `sys_menu` VALUES (2, 1, '[0],[1]', 1, '用户管理', '/sysuser', NULL, 1, 2, 0);
INSERT INTO `sys_menu` VALUES (3, 1, '[0],[1]', 1, '日志管理', '/syslog', NULL, 2, 2, 0);
INSERT INTO `sys_menu` VALUES (4, 1, '[0],[1]', 1, '业务一', '/biz1', NULL, 3, 2, 0);
INSERT INTO `sys_menu` VALUES (5, 1, '[0],[1]', 1, '业务二', '/biz2', NULL, 4, 2, 0);
-- ----------------------------
-- Table structure for sys_org
-- ----------------------------
DROP TABLE IF EXISTS `sys_org`;
CREATE TABLE `sys_org` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`org_pid` bigint(20) NULL DEFAULT NULL COMMENT '上级组织id',
`org_pids` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所有父节点id ',
`is_leaf` tinyint(1) NULL DEFAULT NULL COMMENT '是否叶子节点(0-否,1-是)',
`org_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织名称',
`address` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '地址',
`phone` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电话',
`email` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱',
`sort` int(11) NULL DEFAULT NULL COMMENT '排序',
`level` int(11) NULL DEFAULT NULL COMMENT '组织层级',
`status` tinyint(1) NULL DEFAULT NULL COMMENT '是否禁用(0-否,1-是)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_org
-- ----------------------------
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` bigint(22) NOT NULL AUTO_INCREMENT COMMENT 'id',
`role_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色名称',
`role_desc` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色描述',
`role_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色编码( ADMIN、COMMON)',
`sort` int(11) NULL DEFAULT NULL COMMENT '排序',
`status` tinyint(1) NULL DEFAULT NULL COMMENT '是否禁用(0-否,1-是)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, '管理员', '系统管理员,查看所有', 'admin', 1, 0);
INSERT INTO `sys_role` VALUES (2, '普通用户', '普通用户,首页,业务一,业务二', 'common', 2, 0);
-- ----------------------------
-- Table structure for sys_role_api
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_api`;
CREATE TABLE `sys_role_api` (
`role_id` bigint(20) NOT NULL COMMENT '角色id',
`api_id` bigint(20) NOT NULL COMMENT '接口id '
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role_api
-- ----------------------------
-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`role_id` bigint(20) NOT NULL COMMENT '角色id',
`menu_id` bigint(20) NOT NULL COMMENT '菜单id'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO `sys_role_menu` VALUES (1, 1);
INSERT INTO `sys_role_menu` VALUES (1, 2);
INSERT INTO `sys_role_menu` VALUES (1, 3);
INSERT INTO `sys_role_menu` VALUES (1, 4);
INSERT INTO `sys_role_menu` VALUES (1, 5);
INSERT INTO `sys_role_menu` VALUES (2, 1);
INSERT INTO `sys_role_menu` VALUES (2, 4);
INSERT INTO `sys_role_menu` VALUES (2, 5);
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户名',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '密码',
`org_id` bigint(20) NULL DEFAULT NULL COMMENT '组织id',
`enabled` tinyint(1) NULL DEFAULT NULL COMMENT '是否有效(0-无效用户,1-有效用户)',
`phone` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机号',
`email` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$pcGlvILrp6x4VMAKgt1Cb.gdxTeTLqg5VFd9qIobWVGxs51ccpxfm', NULL, 1, '1569236410', '185625412@qq.com', '2021-09-29 18:45:55');
INSERT INTO `sys_user` VALUES (2, 'user', '$2a$10$YphX7rrDUOEYxSJoOzWkCu4tidQ9N1PYjxMkyXcqJ2iQEg9lrZn7W', NULL, 1, '1352654214', '2365412@163.com', '2021-10-02 18:45:59');
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`user_id` bigint(20) NOT NULL COMMENT '用户id',
`role_id` bigint(20) NOT NULL COMMENT '角色id'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1;
```
## 动态加载用户角色权限
### 定义用户类
```java
@Data
public class MyUserDetails implements UserDetails {
// 密码
private String password;
// 用户名
private String username;
// 账号是否没过期
private boolean accountNoneExpired;
// 账号是否没锁定
private boolean accountNoneLocked;
// 密码是否没过期
private boolean credentialsNoneExpired;
// 账号是否可用
private boolean enabled;
// 用户权限集合
private Collection extends GrantedAuthority> authorities;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
```
### 用户信息查询 Mapper
```java
public interface MyUserDetailsServiceMapper extends BaseMapper {
// 根据用户id查询用户信息
@Select("SELECT username,password,enabled \n" +
"FROM sys_user u \n" +
"WHERE u.username = #{userId}")
MyUserDetails findByUserName(@Param(value = "userId") String userId);
// 根据用户id查询用户角色
@Select("SELECT role_code \n" +
"FROM sys_role r \n" +
"LEFT JOIN sys_user_role ur ON ur.role_id = r.id \n" +
"LEFT JOIN sys_user u ON u.id = ur.user_id \n" +
"WHERE u.username = #{userId}")
List findRoleByUserName(@Param(value = "userId") String userId);
// 根据用户角色查询用户权限
@Select({
""
})
List findAuthorityByRoleCodes(@Param(value = "roleCodes") List roleCodes);
}
```
### 登录业务逻辑
```java
@Component
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private MyUserDetailsServiceMapper myUserDetailsServiceMapper;
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
// 用户基础数据加载
MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(userName);
if (Objects.isNull(myUserDetails)) {
throw new UsernameNotFoundException("用户名不存在");
}
// 用户角色加载
List roleCodes = myUserDetailsServiceMapper.findRoleByUserName(userName);
// 根据角色列表加载当前用户所有权限
List authorities = myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes);
roleCodes = roleCodes.stream()
.map(rc -> "ROLE_" + rc)
.collect(Collectors.toList());
authorities.addAll(roleCodes);
myUserDetails.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(
String.join(",", authorities)
));
return myUserDetails;
}
}
```
### 修改配置文件
```java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private CustomExpiredSessionStrategy customExpiredSessionStrategy;
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
// formLogin
.formLogin()
// 登录页面配置
.loginPage("/login.html")
// 处理登录业务逻辑的接口url
.loginProcessingUrl("/login")
// 登录 用户名 请求参数名
.usernameParameter("username")
// 登录 密码 请求参数名
.passwordParameter("password")
.successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailureHandler)
// 登录成功跳转的url地址
//.defaultSuccessUrl("/")
// 登录失败跳转的url地址
//.failureUrl("/login.html")
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
// user 和 admin 角色可以访问的资源
//.antMatchers("/", "/biz1", "biz2")
.antMatchers("/").hasAnyAuthority("/")
.antMatchers("/biz1").hasAnyAuthority("/biz1")
.antMatchers("/biz2").hasAnyAuthority("/biz2")
// hasAnyAuthority("role_user") 等价与 hasAnyRole("user")
// admin 可以访问的资源
.antMatchers("/sysuser").hasAnyAuthority("/sysuser")
.antMatchers("/syslog").hasAnyAuthority("/syslog")
// .antMatchers("/syslog", "/sysuser")
.anyRequest()
.authenticated()
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1)
// true 表示已经登录,就不允许再登陆
// false 表示允许再次登录,但是之前登录的账号会被踢下线
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(customExpiredSessionStrategy);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService)
// 验证密码时候匹配 BCryptPasswordEncoder 加密
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
// 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要
web.ignoring().antMatchers("/css/**", "/fonts", "/img/**", "/js/**");
}
}
```
## 动态加载资源鉴权规则
```java
@Component("rbaService")
public class MyRBAService {
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails) {
UserDetails userDetails = (UserDetails) principal;
Collection extends GrantedAuthority> grantedAuthorities = userDetails.getAuthorities();
// "/syslog"
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(request.getRequestURI());
return grantedAuthorities.contains(simpleGrantedAuthority);
}
return false;
}
public boolean checkUserId(Authentication authentication, Long userId) {
return true;
}
}
```
> 修改配置文件
```java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
// formLogin
.formLogin()
// 登录页面配置
.loginPage("/login.html")
// 处理登录业务逻辑的接口url
.loginProcessingUrl("/login")
// 登录 用户名 请求参数名
.usernameParameter("username")
// 登录 密码 请求参数名
.passwordParameter("password")
.successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailureHandler)
// 登录成功跳转的url地址
//.defaultSuccessUrl("/")
// 登录失败跳转的url地址
//.failureUrl("/login.html")
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
// user 和 admin 角色可以访问的资源
//.antMatchers("/", "/biz1", "biz2")
/* .antMatchers("/").hasAnyAuthority("/")
.antMatchers("/biz1").hasAnyAuthority("/biz1")
.antMatchers("/biz2").hasAnyAuthority("/biz2")
// hasAnyAuthority("role_user") 等价与 hasAnyRole("user")
// admin 可以访问的资源
.antMatchers("/sysuser").hasAnyAuthority("/sysuser")
.antMatchers("/syslog").hasAnyAuthority("/syslog")*/
.anyRequest().access("@rbaService.hasPermission(request,authentication)") .antMatchers("/person/{id}").access("@rbaService.checkUserId(authentication,#id)")
// .antMatchers("/syslog", "/sysuser")
//.anyRequest()
//.authenticated()
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1)
// true 表示已经登录,就不允许再登陆
// false 表示允许再次登录,但是之前登录的账号会被踢下线
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(customExpiredSessionStrategy);
}
```
## 权限表达式使用方法
> 权限表达式全局使用
```java
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
.anyRequest().access("@rbaService.hasPermission(request,authentication)")
```
> 表达或者的关系
```java
.and()
.authorizeRequests()
// 不需要登录就可以访问的资源
.antMatchers("/login.html", "/login").permitAll()
.antMatchers("/system/**").access("hasAnyRole('admin') or hasAuthority('ROLE_admin')")
.anyRequest().authenticated()
```
> 常用表达式函数及对象
| 表达式函数 | 描述 |
| ------------------------------------------ | ------------------------------------------------------------ |
| hasRole([role]) | 用户拥有指定角色时返回true (Spring Security 默认带有 `ROLE_` 前缀) |
| hasAnyRole([role1],[role2]) | 用户拥有任何指定的角色返回true |
| hasAuthority([authority]) | 用有某资源的访问权限时返回true |
| hasAnyAuthority([authority1],[authority2]) | 拥有某些资源其中部分资源的访问权限返回true |
| permitAll | 永远返回true |
| denyAll | 永远返回false |
| anonymous | 当前用户匿名时返回true |
| rememberMe | 当前用户勾选记住密码,返回true |
| authentication | 当前用户的 `authentication` 对象 |
| fullAuthenticated | 当前用户既不是 `anonymous` 也不是 `rememberMe` 返回 true |
| hasIpAddress('192.168.1.0/24') | 请求发送的IP匹配时返回true |
> 方法级别的安全控制
- `@PreAuthorize("SPEL")`
- `@PreFilter("SPEL")`
- `@PostAuthrize("SPEL")`
- `@PostFilter("SPEL")`
**测试service**
Spring Security 配置类必须添加 `@EnableGlobalMethodSecurity(prePostEnabled = true)` 注解,才可以生效
```java
@Slf4j@Servicepublic class MethodELService { // 只有 admin 角色用户可以访问 @PreAuthorize("hasAnyRole('admin')") public List findAll() { List list = new ArrayList<>(); list.add(new Person("张三")); list.add(new Person("李四")); list.add(new Person("tom")); return list; } // 只返回 和当前登录用户的 用户名 一致的数据 @PostAuthorize("returnObject.name == authentication.name") public Person findOne() { String name = SecurityContextHolder.getContext().getAuthentication().getName(); log.info("name:{}", name); return new Person("admin"); } // 请求参数 过滤 成 偶数 @PreFilter(filterTarget = "ids", value = "filterObject%2==0") public void delete(List ids, List usernames) { log.info("ids:{}",ids); } // 只返回 和当前登录用户的 用户名 一致的数据 @PostFilter("filterObject.name == authentication.name") public List findAllPerson() { List list = new ArrayList<>(); list.add(new Person("admin")); list.add(new Person("jack")); return list; }}
```
**测试 controller**
```java
@Slf4j@Controllerpublic class IndexController { @Autowired private MethodELService methodELService; @PostMapping("/login") public String index(String userName, String password) { return "index"; } @GetMapping("/syslog") public String syslog() { return "syslog"; } @GetMapping("/sysuser") public String sysuser() { return "sysuser"; } @GetMapping("/biz1") public String biz1() { List all = methodELService.findAll(); log.info("findAll:{}", all); Person person = methodELService.findOne(); log.info("findOne:{}", person); List ids = new ArrayList<>(); ids.add(1); ids.add(2); methodELService.delete(ids, null); List allPerson = methodELService.findAllPerson(); log.info("findAllPerson:{}", allPerson); return "biz1"; } @GetMapping("/biz2") public String biz2() { return "biz2"; }}
```
# RememberMe
> 后端
```java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.rememberMe()
// 设置 form 表单 参数名
.rememberMeParameter("remember-me-new")
// 设置保存到浏览器的cookie 名称
.rememberMeCookieName("remember-me-cookie")
// 设置 token 有效期 默认 2 周
.tokenValiditySeconds(2 * 24 * 60 * 60)
// 持久化到数据库
.tokenRepository(persistentTokenRepository());
}
@Autowired
private DataSource dataSource;
// 持久化 登录 token 到数据库
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
```
> 前端
```html
```
> 创建持久化数据库表
```sql
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
```
> RememberMeToken 组成部分
- RememberMeToken = Base64(username + expiryTime + signatureValue)
- signatureValue = username 、 expiryTime 和 username 时一个预定的key,并将他们经过MD5进行签名。
# 退出登录
> logout 的默认行为
- 当前 session 失效,即 logout的核心需求,session失效就是访问权限的回收
- 删除当前用户的 remember-me 功能信息
- clear 清除当前的 SecurityContent
- 重定向到登录页面,loginPage 配置的指定页面
> 配置
```java
@Autowired
private MyLogoutSuccessHandler myLogoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
// 指定退出请求的 url,html 中退出按钮请求url 也需要修改
.logoutUrl("/logout")
// 退出成功跳转页面
.logoutSuccessUrl("/login.html")
// 退出登录的业务逻辑 不能和 logoutSuccessUrl 一块使用
.logoutSuccessHandler(myLogoutSuccessHandler)
// 退出时 删除 指定 cookie
.deleteCookies("JESSION")
}
@Autowired
private DataSource dataSource;
// 持久化 登录 token 到数据库
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
```
> 退出登录逻辑
```java
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
response.sendRedirect("/login.html");
}
}
```
> 前端页面修改
```html
业务系统管理
业务系统管理
退出
日志管理
用户管理
业务一
业务二
```
# JWT 集成
## 认证流程
- `JwtAuthenticationTokenFilter` 检查 是否携带token,并解析用户信息
- `UserDetailsService` 根据用户名加载用户信息
- `JwtTokenUtils` 根据用户信息检查token有效性
- `ApiController`
## 集成
### 添加依赖
```xml
io.jsonwebtoken jjwt 0.9.1
```
### 封装工具类
```java
@Data@Component@ConfigurationProperties(prefix = "jwt")public class JwtTokenUtils { private String secret; private Long expiration; private String header; /** * 生成token * * @param userDetails 用户 * @return 令牌 token */ public String generateToken(UserDetails userDetails) { Map claims = new HashMap<>(); claims.put("sub", userDetails.getUsername()); claims.put("created", new Date()); return generateToken(claims); } 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 令牌 * @return 用户名 */ public String getUsernameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 从 claims 生成令牌 * * @param claims * @return */ private String generateToken(Map claims) { Date expirationDate = new Date(System.currentTimeMillis() + expiration); return Jwts.builder().setClaims(claims) .setExpiration(expirationDate) .signWith(SignatureAlgorithm.ES512, secret) .compact(); } /** * 验证令牌 * * @param token 令牌 * @param userDetails 用户信息 * @return 是否有效 */ public Boolean validateToken(String token, UserDetails userDetails) { MyUserDetails myUserDetails = (MyUserDetails) userDetails; String username = getUsernameFromToken(token); return (username.equals(myUserDetails.getUsername()) && !isTokenExpired(token)); } /** * 判断令牌是否过期 * * @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 */ private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (ExpiredJwtException e) { claims = e.getClaims(); } catch (Exception e) { claims = null; } return claims; }}
```
#### 配置文件
```yaml
jwt: secret: abashajkqewqw213 expiration: 3600000 header: JWTHeaderName
```
#### service
```java
@Servicepublic class JWTAuthService { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Autowired private JwtTokenUtils jwtTokenUtils; public String login(String username, String password) throws CustomException { try { UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password); Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken); SecurityContextHolder.getContext().setAuthentication(authenticate); } catch (AuthenticationException e) { throw new CustomException(CustomExceptionType.USER_INPUT_ERROR, "用户名密码错误"); } UserDetails userDetails = userDetailsService.loadUserByUsername(username); return jwtTokenUtils.generateToken(userDetails); } public String refreshToken(String oldToken) { if (jwtTokenUtils.isTokenExpired(oldToken)) { return jwtTokenUtils.refreshToken(oldToken); } return null; }}
```
#### Controller
```java
@RestControllerpublic class JWTAuthController { @Autowired private JWTAuthService jwtAuthService; @GetMapping("/authentication") public AjaxResponse login(@RequestBody Map map) { String username = map.get("username"); String password = map.get("password"); if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { return AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, "用户名密码不能为空"); } try { String token = jwtAuthService.login(username, password); return AjaxResponse.success(token); } catch (CustomException e) { return AjaxResponse.error(e); } } @GetMapping("/refreshToken") public AjaxResponse refreshToken(@RequestHeader("${jwt.header}") String token) { return AjaxResponse.success(jwtAuthService.refreshToken(token)); }}
```
#### 配置类
```java
@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private MyExpiredSessionStrategy myExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private MyLogoutSuccessHandler myLogoutSuccessHandler; @Autowired private CaptchaCodeFilter captchaCodeFilter; @Autowired private SmsCodeSecurityConfig smsCodeSecurityConfig; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) .apply(smsCodeSecurityConfig) .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/authentication","/refreshToken").permitAll() .anyRequest().access("@rbaService.hasPermission(request,authentication)") .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) .and() .logout() .logoutUrl("/logout") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() // 基于token,所以不需要session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(myExpiredSessionStrategy); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService) // 验证密码时候匹配 BCryptPasswordEncoder 加密 .passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(WebSecurity web) throws Exception { // 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要 web.ignoring().antMatchers("/css/**", "/fonts", "/img/**", "/js/**"); } @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; } @Override @Bean(name = BeanIds.AUTHENTICATION_MANAGER) public AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); }}
```
### 集成 Security
#### 编写过滤器
```java
@Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtils jwtTokenUtils; @Autowired private MyUserDetailsService myUserDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String jwtToken = request.getHeader(jwtTokenUtils.getHeader()); if (StringUtils.isNotEmpty(jwtToken)) { String username = jwtTokenUtils.getUsernameFromToken(jwtToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = myUserDetailsService.loadUserByUsername(username); if (jwtTokenUtils.validateToken(jwtToken, userDetails)) { // 给当前 JWT 令牌 用户 授权 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } } filterChain.doFilter(request, response); }}
```
#### 配置过滤器
```java
@Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Override protected void configure(HttpSecurity http) throws Exception { // CSRF禁用,因为不使用session http.csrf().disable() // 认证失败处理类 .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) .apply(smsCodeSecurityConfig) .and() // 过滤请求 .authorizeRequests() // 只允许 不登录(匿名) 访问 .antMatchers("/login", "/captchaImage").anonymous() // 登录 不登录 都允许 访问 .antMatchers("/authentication", "/refreshToken").permitAll() .anyRequest().access("@rbaService.hasPermission(request,authentication)") // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated() .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) .and() .logout() .logoutUrl("/logout") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() // 基于token,所以不需要session .sessionCreationPolicy(SessionCreationPolicy.STATELESS); }}
```
#### 测试 controller
```java
@RestControllerpublic class HelloControllerController { @GetMapping("/hello") public String hello(String name) { return name; }}
```
登录成功后,携带 token 访问 这个接口,记得在数据库给当前对应角色,分配权限
```sql
INSERT INTO `bootdb`.`sys_menu` (`id`, `menu_pid`, `menu_pids`, `is_leaf`, `menu_name`, `url`, `icon`, `sort`, `level`, `status`) VALUES (6, 1, '1', 1, 'hello', '/hello', NULL, 5, 2, 0);INSERT INTO `bootdb`.`sys_role_menu` (`role_id`, `menu_id`) VALUES (1, 6);
```
# 跨域
## 前端解决方案
- html 的 script 标签
- html 的 link 标签
- html 的 img 标签
- html 的 iframe 标签: 对于使用 jsp、freemarker开发的项目,这是实现跨域访问最常见的方式
## 使用代理解决
## CORS
- `Access-Control-Origin` 允许哪些IP或者域名可以跨域访问
- `Access-Control-Max-Age` 表示在多少秒内不需要重复校验该请求的跨域访问权限
- `Access-Control-Allow-Methods` 表示允许跨域访问请求的HTTP方法,如:GET、POST、PUT、DELETE
- `Access-Control-Allow-Headers` 表示访问资源允许携带哪些Header信息,如: Accept、Accept-Lanauage、Content-Length、Content-Lanaguage、Content-Type
## 测试跨域 js
```js
window.onload = function (ev) { var headers = {"JWTHeaderName": ''}; $.ajax({ url: 'http://localhost:8888/hello', type: 'POST', headers: headers, success: function (data) { alert('跨域访问成功'); }, error: function (e) { alert('跨域访问失败'); } }) let kaptchaImage = document.getElementById("kaptcha"); kaptchaImage.onclick = function (ev1) { kaptchaImage.src = "/kaptcha?" + Math.floor(Math.random() * 100); } }
```
## 开启跨域
```java
@Override protected void configure(HttpSecurity http) throws Exception { // 开启跨域 http.cors(); } // 配置跨域 @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:8080")); corsConfiguration.setAllowedMethods(Arrays.asList("POST","GET")); corsConfiguration.applyPermitDefaultValues(); UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource(); configurationSource.registerCorsConfiguration("/**", corsConfiguration); return configurationSource; }
```
## CSRF 跨站攻击防御
- `CORS` 跨站资源共享,,局部打破同源策略限制,使在一定规则下HTTP请求可以突破浏览器限制,实现跨站访问
- `CSRF` 是一种网络攻击方式,也可以说是一种安全漏洞,这个安全漏洞在web开发中广泛存在,我们需要堵住这个漏洞
> 配置
```java
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf() // 使用 cookie 保存 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // csrf 忽略登录url,只针对所有POST 请求防御 .ignoringAntMatchers("/authentication") }
```
> 测试
- 登录时,cookie 会返回 `XSRF-TOKEN`
- 所有 `POST` 请求 访问接口的时候,请求 `Header` 中除了要携带 `JWTHeaderName` ,还需要携带 `X-XSRF-TOKEN`
> 前端携带参数方式
- Header 中携带 CSRF token
```js
var headers={};headers['X-XSRF-TOKEN'] = "${_csrf.token}";$ajax({ headers: headers})
```
- 直接作为参数提交
```js
$ajax({ data:{ '_csrf':"${_csrf.token}" }})
```
- form 表单的隐藏字段
```html
```