登錄認證
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中添加如下代碼:
proxyTable: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: {
'^/': ''
}
}
}
而在Spring Boot的config包下添加CorsConfig配置類,代碼如下:
@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方法實現如下:
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:
@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
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
// 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
// 認證是否登錄
@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
// 判斷有沒有權限
@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
// 退出登錄成功
@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
@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
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
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
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並跳轉到首頁。