在上一篇文章 SpringSecurity - 实战 RBAC(一) 中,我们初步搭建了前后端项目,接下来这篇文章中我们就打通前后端项目,并实现初步的登录功能
SpringSecurity
默认使用 Session
来保存用户登录状态,现在大部分都是前后端分离的项目,都会使用 token
来做身份认证,客户端在请求服务端的时候会在请求头中携带一个表示其身份的标识 token
,一般都会使用 JWT
,但基于我多年的 SpringSecurity
项目开发经验,JWT
中存储的信息一般不会太多,也不会把用户的资源授权信息存到 JWT
中,也就是说如果使用 JWT
,那么到服务端的时候还是需要再去查询一次用户信息来获取到授权信息的;并且使用 JWT
还有一个缺点,就是 token
一旦下发给客户端,那么就不受服务端控制了,就不能做 被挤下线
、强制下线
等功能了,所以这里就不采用 JWT
这种普遍的解决方案
Redis
综上所述,我们引入 Redis
来存储 token
,做第一步认证处理,如果 token
不存在则需要重新登录:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
并配置 application.yml
:
spring:
redis:
host: 192.168.228.18
我们要做的是一个前后端分离的项目,所以并不需要默认的登录页面
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 关闭默认的登录
.formLogin().disable()
.httpBasic().disable()
// 关闭默认的登出
.logout().disable()
// 不使用 Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
httpSecurity
.authorizeRequests()
// 所有请求都需要认证通过后才可以访问
.anyRequest().authenticated();
return httpSecurity.build();
}
}
/login
因为接下来我们会对接数据库,所以使用 UserDetailsService
来模拟数据库操作,后面直接修改为查询数据库代码即可。同时 SpringSecurity
的配置也是比较有意思的,一环套一环,如果有不理解的地方,建议先把 SpringSecurity 学习 专栏的 启动流程分析
先阅读完,之后再来进行实战
WebSecurityConfig
:@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 关闭 csrf 防御
.csrf().disable()
// 关闭默认的登录
.formLogin().disable()
.httpBasic().disable()
// 关闭默认的登出
.logout().disable()
// 不使用 Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
httpSecurity
.authorizeRequests()
// 所有请求都需要认证通过后才可以访问
.anyRequest().authenticated();
return httpSecurity.build();
}
/**
* 这里不理解的可以查看 https://blog.csdn.net/qiaohao0206/article/details/126338492 这篇文章中对从 AuthenticationConfiguration 中获取 AuthenticationManager
*
* @param authenticationConfiguration
* @return
* @throws Exception
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
// 登录接口请求参数
@Data
public class LoginParam {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
@RestController
public class LoginController {
@Autowired
private RedisUtils redisUtils;
@Autowired
private AuthenticationManager authenticationManager;
@PostMapping("login")
public R<?> login(@RequestBody @Valid LoginParam param) {
// 组装认证参数
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword());
// 认证成功会返回 Authentication 对象
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// token
String token= UUID.randomUUID().toString();
// 把用户主体和 token 存到 redis 中
redisUtils.set(token, authenticate.getPrincipal().toString(), 6, TimeUnit.HOURS);
return R.success(token);
}
}
UserDetailsService
接口,完善认证逻辑// 加入到 IoC 容器中,会自动配置到认证逻辑中
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 这里模拟查询数据库操作,后面对接数据库的时候只需要修改这里就可以了
if ("admin".equals(username)) {
// 自定义的用户实体,实现 UserDetails 接口
CurrentUser currentUser = new CurrentUser();
currentUser.setUsername(username);
currentUser.setPassword(passwordEncoder.encode("123456"));
return currentUser;
}
throw new UsernameNotFoundException("账号或密码错误");
}
}
@Data
public class CurrentUser implements UserDetails {
private String username;
private String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 提供一个默认的 sale 权限,后面对接数据库的时候再完善
List<GrantedAuthority> authorityList = new ArrayList<>();
SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority("sale");
authorityList.add(grantedAuthority);
return authorityList;
}
@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 true;
}
@Override
public String toString() {
return JSON.toJSONString(this);
}
}
/login
接口不需要认证@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().antMatchers("/login");
}
postman
请求 /login
接口在 utils
包下的 request.js
中,我们可以看到 axios
使用 VUE_APP_BASE_API
作为请求地址,这里我们在 .env.development
中修改开发环境的请求地址:
# just a flag
ENV = 'development'
# base api
VUE_APP_BASE_API = 'http://127.0.0.1:9626'
接着在 api
包下的 user.js
修改 login
方法中对于 /login
接口的请求:
export function login(data) {
return request({
url: '/login',
method: 'post',
data
})
}
修改 store
包下的 modules
包中 user.js
中的 actions
中的 login
的返回结果处理:
login({ commit }, userInfo) {
const { username, password } = userInfo
return new Promise((resolve, reject) => {
login({ username: username.trim(), password: password }).then(res => {
const { data } = res
commit('SET_TOKEN', data)
setToken(data)
resolve()
}).catch(error => {
reject(error)
})
})
}
在 views/login
包下的 index.vue
中,handleLogin
方法中我们看到登录请求成功之后,会跳转到 /
路径下,而在 permission.js
中 router
的前置拦截器中我们可以看到,当 Cookie
中已经存在 token
,且请求的不是 /login
路径时,会判断 VueX
中是否包含 name
这一属性,如果没有的话会请求 user/getInfo
这一 action
,通过查看代码,可以看到是请求的 api
目录下的 user.js
中的 getInfo
方法,所以还需要后端提供查询用户信息的接口,同时返回 name
和 avatar
两个参数:
@RestController
@RequestMapping("user")
public class UserController {
@GetMapping("info")
public R<Map<String, String>> userInfo(@AuthenticationPrincipal CurrentUser currentUser) {
Map<String, String> result = new HashMap<>();
result.put("name", currentUser.getUsername());
result.put("avatar", "https://tupian.qqw21.com/article/UploadPic/2022-5/20225221202284170.jpg");
return R.success(result);
}
}
token
utils
包下的 auth.js
中 Cookie
中存放 token
的命名import Cookies from 'js-cookie'
const TokenKey = 'token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
utils
包下的 reqeust.js
中请求携带 token
的命名CorsFilter
到 IoC 容器
中@Configuration
public class CorsConfig {
private CorsConfiguration buildConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOriginPattern("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", buildConfig());
return new CorsFilter(source);
}
}
WebSecurityConfig
本篇文章完成了前后端的联调工作,需要注意跨域问题的解决和 CSRF
为什么要禁用,不懂的话可以看前几篇文章,接下来会对接数据库完成认证和前后端的鉴权