登录认证
Spring Security实现登录认证主要借助其一系列过滤器链,而其中和登录最相关的就是UsernamePasswordAuthenticationFilter
。但是这个过滤器只能实现基本的表单登录,表单中只能有用户名(username)和密码(password)。如果我们想自定义我们的登录表单,就必须自己实现一个过滤器,并且继承这个UsernamePasswordAuthenticationFilter
。
JWT
JWT,即JSON Web Token,由三部分组成:Header, Payload, Signature,并且之间由圆点(.)隔开。
JWT可以实现权限认证功能,当用户登录成功后,服务端会生成一个token传递给客户端。用户后面的每一个请求都包含了这个token,服务端解析出这个token从而判断出用户拥有的权限和能访问的资源。
JWT和之前使用的session不同,session必须保存在服务端,会增加内存开销。而且session在集群和分布式系统中需要共享,通常由Redis实现,而JWT不需要。
跨域配置
前后端分离的项目中一般都会遇到跨域的问题,我们可以通过配置来解决跨域的问题。
在Vue的index.js
中添加如下代码:
js
proxyTable: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: {
'^/': ''
}
}
}
而在Spring Boot的config
包下添加CorsConfig
配置类,代码如下:
java
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
//允许源,这里允许所有源访问,实际应用会加以限制
corsConfiguration.addAllowedOrigin("*");
//允许所有请求头
corsConfiguration.addAllowedHeader("*");
//允许所有方法
corsConfiguration.addAllowedMethod("*");
source.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(source);
}
}
这样前后端分离的项目的跨域问题将会得以解决。
但是,在引进Spring Security后又会出现跨域问题😂,此时需要在config/SecurityConfig
中再次进行跨域配置。代码在下面的后端部分呈现。
后端部分
项目主要有两类用户:普通用户和系统管理员。那么我一开始就直接简化处理了😂,将两者合并为一个类User
,并且实现UserDetails
接口,数据表中添加一个条目为role
,类型为String
,也就是角色属性,用来控制权限的。role
属性我分成了两类:USER
和ADMIN
。
getAuthorities
方法实现如下:
java
public Collection<? extends GrantedAuthority> getAuthorities() {
// String[] roles = role.split(",");
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
// for (String s : roles) {
// authorities.add(new SimpleGrantedAuthority(s));
// }
authorities.add(new SimpleGrantedAuthority(role));
return authorities;
}
这里有点奇怪的部分是本来我参考的文章中会出现类似role
属性中既有USER
又有ADMIN
,即ADMIN,USER
,那么就需要对字符串进行分割。但是我认为完全可以简化处理,只保留一个角色即可。对于拥有多个角色的用户,可以只保留拥有最高权限的那个角色。
对于UserDetailsService
接口,我们也要将其实现。这里我一开始使用了UserServiceImpl
实现,但是感觉不好,所以后来使用MyUserDetailsService
实现UserDetailsService
:
java
@Service
public class MyUserDetailsService implements UserDetailsService{
@Autowired
private UserServiceImpl userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.query().eq("username", username).one();
if (user == null) {
throw new UsernameNotFoundException("用户不存在!");
}
return user;
}
}
这里使用MyBatis Plus直接从数据库中查询用户,所以就不需要在mapper中写SELECT
语句来操作数据库。
接下来配置各种过滤器。
LoginFilter
java
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
if (request.getContentType().contains(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().contains(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
Map<String, String> loginData = new HashMap<>();
try {
loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
} catch (IOException e) {
}
String username = loginData.get("username");
String password = loginData.get("password");
if (username == null)
username = "";
if (password == null)
password = "";
username = username.trim();
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(username, password);
User principal = new User();
principal.setUsername(username);
return this.getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
}
else
return super.attemptAuthentication(request, response);
}
}
JWTAuthenticationFilter
java
// JWT认证过滤器
@Component
public class JWTAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = request.getHeader("Authorization");
if (StrUtil.isBlankOrUndefined(token)) {
chain.doFilter(request, response);
return;
}
JWT jwt = JWTUtil.parseToken(token);
try {
JWTValidator.of(token).validateDate(DateUtil.date());
} catch (ValidateException exception) {
throw new JWTException("token已过期");
}
String username = jwt.getPayload("username").toString();
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(username, null,
myUserDetailsService.loadUserByUsername(username).getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
chain.doFilter(request, response);
}
}
JWTAuthenticationEntryPoint
java
// 认证是否登录
@Component
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(response.SC_UNAUTHORIZED);
ServletOutputStream out = response.getOutputStream();
Result result = new Result(401, "请先登录", "");
out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
JWTAccessDeniedHandler
java
// 判断有没有权限
@Component
public class JWTAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(response.SC_FORBIDDEN);
ServletOutputStream out = response.getOutputStream();
Result result = new Result(403, accessDeniedException.getMessage(), "");
out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
JWTLogoutSuccessHandler
java
// 退出登录成功
@Component
public class JWTLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
if (authentication != null)
new SecurityContextLogoutHandler().logout(request, response, authentication);
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream out = response.getOutputStream();
Result result = new Result(200, "退出登录成功", "");
out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
下面需要配置Spring Security。
SecurityConfig
java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
@Autowired
private JWTAccessDeniedHandler jwtAccessDeniedHandler;
@Autowired
private JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JWTLogoutSuccessHandler jwtLogoutSuccessHandler;
@Autowired
private JWTAuthenticationFilter jwtAuthenticationFilter;
// 注入AuthenticationManager
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
// 加密,数据库中必须保存加密后的密码
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.setContentType("application/json;charset=utf-8");
ServletOutputStream out = response.getOutputStream();
User user = (User) authentication.getPrincipal();
// 密钥
byte[] key = "1234567890".getBytes();
// 使用hutool生成JWT
String token = JWT.create()
.setPayload("username", user.getUsername())
.setExpiresAt(DateUtil.offset(DateUtil.date(), DateField.DAY_OF_MONTH, 1))
.setKey(key)
.sign();
LoginVO loginVO = new LoginVO(user.getId(), token, user.getAvatar());
Result result = new Result(200, "登录成功", loginVO);
out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
});
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
response.setContentType("application/json;charset=utf-8");
ServletOutputStream out = response.getOutputStream();
Result result = new Result(400, "", "");
if (exception instanceof LockedException)
result = new Result(400, "账户被锁定,请联系管理员!", "");
else if (exception instanceof CredentialsExpiredException)
result = new Result(400, "密码过期,请联系管理员!", "");
else if (exception instanceof AccountExpiredException)
result = new Result(400, "账户过期,请联系管理员!", "");
else if (exception instanceof DisabledException)
result = new Result(400, "账户被禁用,请联系管理员!", "");
else if (exception instanceof BadCredentialsException)
result = new Result(400, "用户名或者密码输入错误,请重新输入!", "");
out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
});
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/login");
return loginFilter;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable() // 禁用csrf,但不安全
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 设置无状态
.and()
.authorizeRequests()
.antMatchers("/code").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers().frameOptions().disable();
http.logout().logoutUrl("/logout").logoutSuccessHandler(jwtLogoutSuccessHandler);
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
// 添加jwt filter
http.addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
// 跨域配置
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// 允许跨域访问的 URL
List<String> allowedOriginsUrl = new ArrayList<>();
allowedOriginsUrl.add("http://localhost:8080");
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
// 设置允许跨域访问的 URL
config.setAllowedOrigins(allowedOriginsUrl);
config.addAllowedHeader("*");
config.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER"); // 角色继承
return hierarchy;
}
}
这里使用的hasRole
,所以数据库中role
属性需要加ROLE_
前缀,但是如果使用hasAuthority
,就不需要加上ROLE_
前缀。authority是权限,role则是角色,角色是权限的集合,但在实际使用中,这两者的区别不大,可以混用。
对于以上代码,我们只是通过HttpSecurity
进行用户权限配置,没有实现动态权限配置,不够灵活,在之后的文章中,我会改进这一点。
前端部分
前端我通过Vue来实现,为了简化处理,我将所有页面都归为静态页面,只不过有些页面需要确认权限。
router/index.js
js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export const constantRouter = [
{
path: '/',
name: 'Default',
redirect: '/home',
component: () => import('@/views/home')
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login')
},
{
path: '/home',
name: 'Home',
component: () => import('@/views/home'),
redirect: '/index',
children: [
{
path: '/index',
name: 'Index',
component: () => import('@/views/home/index')
},
path: '/user',
name: 'User',
component: () => import('@/views/user/index')
}
]
},
{
path: '/manage',
name: 'Manage',
component: () => import("@/views/admin/manage"),
},
{
path: '/*',
component: () => import('@/views/error/404'),
}
]
export default new Router({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: constantRouter
})
main.js
js
router.beforeEach((to, from, next) => {
if (to.path === '/login')
next()
else {
// 有token
if (store.getters.token)
next()
// 没有token
else {
next({
path: '/login',
query: {redirect: to.fullPath }
})
}
}
})
utils/request.js
js
import axios from 'axios'
import store from '@/store'
import { Message, MessageBox } from 'element-ui'
import router from '../router'
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
baseURL: process.env.BASE_API,
// 超时
timeout: 10000
})
// request请求拦截
service.interceptors.request.use(
config => {
if (store.getters.token) {
config.headers['Authorization'] = store.getters.token
}
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(response => {
const res = response.data
const code = res.code
if (code === 200)
return res
else
return Promise.reject('error')
},
error => {
if (error.response) {
switch (error.response.status) {
case 401:
MessageBox({title: '提示', message: '请先登录!', type: 'error',
callback: (action)=>{
if (action === 'confirm')
router.replace({path: '/index'})
}})
break;
case 403:
MessageBox({title: '提示', message: '没有权限,请联系管理员!', type: 'error',
callback: (action)=>{
if (action === 'confirm')
router.replace({path: '/index'})
}})
break;
}
}
else
return Promise.reject(error)
}
)
export default service
总结
上面的代码已经能够实现基本的登录认证和权限控制,首先判断用户是否登录,登录成功后分配权限。用户每次请求都会携带token,有权限可以直接访问页面,而没有权限则会显示403并跳转到首页。