記錄SpringSecurity+jwt 登錄

登錄

前言:有的東西久了容易忘記。記錄一下。點點記錄

先了解登錄需要做什麼

  1. 攔截請求,否則登錄沒有意義了
  2. 登錄
  3. 單點登錄?
  4. 怎麼驗證登錄

開始

  1. 創建springboot項目(單體項目記錄輕鬆一點也好理解分佈式瞭解了單體,在知道一些思路就能配出來)
  2. 引入jar包
   	<!-- security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency> 
    <!-- jwt 目前最新0.9.1 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
  1. 目的是要登錄,啷個曉得是登錄嘞,前端請求後端只有個地址和參數。只能從這上面驗證。security有個類 WebSecurityConfigurerAdapter
@Configuration  //配置
@EnableWebSecurity	//啓用security
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AuthenticationConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    JwtAuthorizationTokenFilter authenticationTokenFilter;
    /**
     * 功能描述: <br>
     * 裝載BCrypt密碼編碼器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 功能描述: <br>
     * 自定義攔截
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                // 對於獲取token、swagger api不攔截
                .antMatchers("/login","/swagger-ui.html/**","/swagger-resources/**", "/webjars/**", "/v2/**").permitAll()
                //前後端分離-前端會請求兩次,一次是OPTIONS 一次纔是我們的post,如果攔截會停止請求
                .antMatchers(HttpMethod.OPTIONS, "/**").anonymous()
                // 除上面外的所有請求全部需要鑑權認證
                .anyRequest().authenticated()
                .and()
                //禁用 Spring Security 自帶的跨域處理用自定義
                .csrf().disable()
                // 基於token,所以不要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // 禁用緩存
        httpSecurity.headers().cacheControl();
        // 添加JWT驗證請求中的token
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

EnableGlobalMethodSecurity註解詳解
這裏配置是最基本的,配置了一個請求,實際WebSecurityConfigurerAdapter有多個方法,需要根據實際情況。
configure(HttpSecurity http):這個方法配置攔截
configure(WebSecurity web):配置跳過驗證,會跳過配置的jwt攔截
configureGlobal(AuthenticationManagerBuilder auth):配置請求的認證,在jwt攔截之前先去這裏認證一下,根據實際情況而用。

  1. 開啓了security,攔截了請求,開始登錄。
    Security是一個權限框架,有權限就需要設置其他東西,這裏只是登錄。
    我這裏是:先基本的驗證賬號密碼、查詢數據、匹配密碼(待會兒會說)、生成token、返回給前端。
@ApiOperation(value = "登錄接口",notes = "根據用戶名和密碼登錄")
    @PostMapping(value = "/login")
    public Result login(@RequestBody SysUser sysUser) {
        //驗證賬號密碼
        if (StringUtils.isBlank(sysUser.getUsername()) || StringUtils.isBlank(sysUser.getPassword())){
            return Result.fail("請檢查賬號密碼");
        }
        //請求查詢
        SysUser one = sysUserService.getUser(sysUser);
        //如果查詢有數據-生成token
        if (Optional.ofNullable(one).isPresent()){
            BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
            if (!encoder.matches(sysUser.getPassword(),one.getPassword())){
                return Result.fail("賬號/密碼錯誤");
            }
            //因爲只是做登錄不需要拿權限,不需要給Security存儲用戶和角色等信息
            final String token = jwtTokenUtil.generateToken(String.valueOf(one.getId()));
            return Result.success(token);
        }
        return Result.fail("賬號/密碼錯誤");
    }

此處經歷了一個坑
本是通過加密器加密密碼去和數據庫匹配,但加密器是隨機鹽,每次密碼都不一樣,所以纔有用明文密碼查詢之後再調動加密器自帶的方法驗證明文和密文是否相同,這裏驗證鹽值是取得密文中解析的不存在改變。

5.登錄拿到令牌訪問其他接口。這裏我們配置的jwt驗證令牌。

@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {
    @Value("${jwt.tokenHead}")
    private String tokenHead;
    /**
     * 功能描述: <br>
     * 驗證token
     * 驗證authToken
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
    	//獲取請求數據,根據設置不同,如果token就叫token那麼request.getHeader("token");
        final String requestHeader = request.getHeader(this.tokenHeader);
        //我這裏設置id
        String userId = null;
        //token
        String authToken = null;
        //token參數不爲空,並且參數的開頭是**這裏相當於有個鑰匙,例如:token前面加上英文 project 後端在獲取時先驗證通用鑰匙是否正確
        if (requestHeader != null && requestHeader.startsWith(tokenHead)) {
        	//如果通過,取出正確的token,根據實際情況截取
            authToken = requestHeader.substring(7);
            try {
            	//解析token拿到id
                userId = jwtTokenUtil.getUserIdFromToken(authToken);
            } catch (ExpiredJwtException e) {
				//token過期  根據實際情況返回,可以統一拋異常,也可以設置HttpServletResponse,通過Response輸出response.getWriter().write("token過期")
				return;
			} catch (IllegalArgumentException e) {
				//非法參數
				return;
			} catch (Exception e) {
				//token過期,可以直接所有的異常都是token過期友好提醒。
				return;
			}
        }
        //剩下的根據id驗證做其他邏輯驗證
        //然後進行緩存中的token匹配,某個用戶的id和token匹配如果不匹配提示被擠掉了,然後前端直接調用登出達到單點登錄的目的。如果不驗證則沒有單點登錄。緩存可以用數據庫如果嫌緩存麻煩。
		//驗證token有效時間
        if (jwtTokenUtil.validateToken(authToken, userDetail)) {
        	//設置上下文,這樣後面纔可以直接取出id用戶名稱等信息
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetail.getUsername(), userDetail.getPassword(), userDetail.getAuthorities());
            authentication.setDetails(userDetail);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
		//返回
        chain.doFilter(request, response);
    }
}

到此登錄就完了。
下面還有一個 jwttokenUtil。生成token和解析token的

@Component
@Slf4j
public class JwtTokenUtil implements Serializable {
	//公共鑰匙
    @Value("${jwt.secret}")
    private  String secret;
	//過期時間
    @Value("${jwt.expiration}")
    private Long expiration;
	//token參數名稱
    @Value("${jwt.header}")
    private String tokenHeader;
    private Clock clock = DefaultClock.INSTANCE;
    //根據id生成token
    public String generateToken(String id) {
    	Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, id);
    }
    //真正生成token接口
    private String doGenerateToken(Map<String, Object> claims, String subject) {
    	//token創建時間
        final Date createdDate = clock.now();
        //過期時間
        final Date expirationDate = new Date(createdDate.getTime() + expiration);
		//生成token
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
	//驗證token
    public Boolean validateToken(String token, String id) {
    	//先解析token拿到id
        final String username = getUsernameFromToken(token);
        //驗證token和根據token查詢的是否一樣,可以傳入一個用戶實體不傳id,根據實際情況驗證需要的
        return (username.equals(String.valueOf(user.getId()))
                && !isTokenExpired(token)
        );
    }
	//根據token獲取id
    public String getUserIdFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }
	
	//解析token,並且根據token生成時的類型返回
    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
        return claimsResolver.apply(claims);
    }
    
	//驗證token時間是否過期-前提是生成token時設置了
    private Boolean isTokenExpired(String token) {
        final Date expiration = getClaimFromToken(token, Claims::getExpiration);
        return expiration.before(clock.now());
    }
}

到目前能夠實現:

  1. 登錄拿token
  2. 攔截請求驗證是否有憑證(token)
    這裏如果覺得提示不夠友好在上方配置文件中加入 .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
    .and()
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                //如果沒有憑證去自定義的 jwtAuthenticationEntryPoint 裏面處理
             	.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                .authorizeRequests()
                // 對於獲取token的rest api要允許匿名訪問
                .antMatchers("/login","/swagger-ui.html/**","/swagger-resources/**", "/webjars/**", "/v2/**").permitAll()
                //前後端分離-前端會請求兩次,一次是OPTIONS 一次纔是我們的post,如果攔截會停止請求
                .antMatchers(HttpMethod.OPTIONS, "/**").anonymous()
                // 除上面外的所有請求全部需要鑑權認證
                .anyRequest().authenticated()
                .and()
                //禁用 Spring Security 自帶的跨域處理
                .csrf().disable()
                // 基於token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // 禁用緩存
        httpSecurity.headers().cacheControl();
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
    
    然後自己寫一個jwtAuthenticationEntryPoint類
    @Component
    @Slf4j
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    	@Override
    	public void commence(HttpServletRequest request,HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException, ServletException {
    		log.info("-------------------------訪問需要憑證:{}",authException.getMessage());
    		response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"沒有憑證");
    	}
    }
    
    這樣在沒權限訪問時網頁上提示的不再是英文,而是中文,提示用戶,還可以設置其他的就不舉例了
  3. 解析token得到信息(需要自己寫一個公共的方法,例如token中是id,解析id查詢一個用戶信息出來,然後在其他地方,需要用到用戶的時候,直接調一下就可以了,或者把更多的信息存入token,然後解析就用)。

角色權限

基於角色,需要改下登錄

	@PostMapping("/loginRole")
    public Result loginRole(@RequestBody LoginUser sysUser){
        //驗證賬號密碼
        if (StringUtils.isBlank(sysUser.getUsername()) || StringUtils.isBlank(sysUser.getPassword())){
            return Result.fail("請檢查賬號密碼");
        }
        //登錄並獲取角色
        UserDetail userDetails =(UserDetail) userDetailsService.loadUserByUsername(sysUser.getUsername());
        //密碼是否正確
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        if (!encoder.matches(sysUser.getPassword(),userDetails.getPassword())){
            return Result.fail("賬號/密碼錯誤");
        }
        //賬號是否被禁用
        if("1".equals(userDetails.getStatus())){
            return Result.fail("該賬戶已停用,請確認!");
        }
        String token = jwtTokenUtil.generateToken(userDetails.getUsername());
        return Result.success(token);
    }

這裏沒啥好說的 LoginUser 就是自定義的入參實體
然後實現UserDetailsService 方法。

@Service
@Slf4j
public class JwtUserDetailsService implements UserDetailsService {
    @Autowired
    SysUserService sysUserService;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("------當前登錄用戶:{}",username);
        //查詢數據庫-這裏偷懶了,應該是連表查詢自己寫sql,大概是查詢用戶和角色以及權限集合
        QueryWrapper<SysUser> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username",username);
        //用戶
        SysUser one = sysUserService.getOne(queryWrapper);
        if (!Optional.ofNullable(one).isPresent()){
            throw new UsernameNotFoundException("用戶不存在");
        }
        //得到權限
        List<GrantedAuthority> authorityList = new ArrayList<>();
        //這裏假設有一個權限 ROLE_USER
        //真正情況應該是查詢一個集合,然後循環new SimpleGrantedAuthority("ROLE_USER")插入權限
        authorityList.add(new SimpleGrantedAuthority("ROLE_USER"));
        //最後返回一個用戶信息,這個UserDetail是自定義的,繼承了Security中的User,主要是給Security設置上下文,添加權限,這裏有什麼權限需要加載進Security中否則沒辦法驗證權限
        return new UserDetail(one,authorityList);
    }
}

然後在方法上加上註解需要某個權限,就實現了基於角色的權限控制
附上user

@Getter
public class UserDetail extends User {
	/**
	 * 用戶id
	 */
	private Integer id;

	/**
	 * 密碼
	 */
	private String password;
	/**
	 * 賬號
	 */
	private String username;

	
	public UserDetail(SysUser user, Collection<? extends GrantedAuthority> authorities) {
		//給Security設置用戶名和密碼,以及權限集合
		super(user.getUsername(), user.getPassword(), authorities);
		//設置返回的信息
		this.id = user.getId();
		this.username = user.getUsername();
		this.password = user.getPassword();
	}
}

對於攔截器

@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {
    private final UserDetailsService userDetailsService;
    private final JwtTokenUtil jwtTokenUtil;
    private final String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;
    public JwtAuthorizationTokenFilter(@Qualifier("jwtUserDetailsService") UserDetailsService userDetailsService,
                                       JwtTokenUtil jwtTokenUtil, @Value("${jwt.header}") String tokenHeader) {
        this.userDetailsService = userDetailsService;
        this.jwtTokenUtil = jwtTokenUtil;
        this.tokenHeader = tokenHeader;
    }
    /**
     * 功能描述: <br>
     * 首先從header中獲取憑證authToken
     * 取出username看數據中是否有
     * 驗證authToken
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        final String requestHeader = request.getHeader(this.tokenHeader);
        String username = null;
        String authToken = null;
        if (requestHeader != null && requestHeader.startsWith(tokenHead)) {
            authToken = requestHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(authToken);
            } catch (ExpiredJwtException e) {
                response.getWriter().write(JSON.toJSONString(Result.fail(ResultEnum.TOKEN_EXPIRED)));
                return;
            }
        }
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetail userDetail = (UserDetail) this.userDetailsService.loadUserByUsername(username);
            //驗證token有效時間
            if (jwtTokenUtil.validateToken(authToken, userDetail)) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetail.getUsername(), userDetail.getPassword(), userDetail.getAuthorities());
                authentication.setDetails(userDetail);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }
}

然後寫個工具類 entitUtils

public class BaseEntityUtil {
/**
	 * 獲取當前用戶id
	 */
	public static int getUserId() {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (Objects.isNull(authentication)) {
			// 當前爲開發模式
			return USER_ID;
		}
		UserDetail userDetail = (UserDetail) authentication.getDetails();
		return userDetail.getId();
	}
	/**
	 * 獲取當前用戶名
	 */
	public static String getUserName() {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (Objects.isNull(authentication)) {
			// 當前爲開發模式
			return USER_NAME;
		}
		UserDetail user = (UserDetail) authentication.getDetails();
		return user.getUsername();
	}
}

使用時直接用就行了
例如:

	@PostMapping("/save")
    @ResponseBody
    public Result save(@RequestBody SysUserDto userDto){
        if (!Optional.ofNullable(userDto).isPresent()){
            return Result.fail("數據不完整,請填寫數據");
        }
        //查詢賬號是否存在
        QueryWrapper<SysUser> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username",userDto.getUsername());
        SysUser one = sysUserService.getOne(queryWrapper);
        if (null != one){
            return Result.fail("該賬號已經存在,請重新添加");
        }
        //把入參dto轉換爲myplus識別的bean然後獲取id設置創建人修改人
        SysUser user = new SysUser();
        BeanUtils.copyProperties(userDto, user);
        user.setCreateBy(BaseEntityUtil.getUserId());
        user.setUpdateBy(BaseEntityUtil.getUserId());
        //密碼加密
        user.setPassword(passwordEncoder.encode(userDto.getPassword()));
        //添加
        boolean save = sysUserService.save(user);
        return save?Result.success("添加成功"):Result.fail("添加失敗");
    }

到此基於角色的權限以及登錄完成,在方法上加上註解即可
PS:因爲是實現的UserDetailsService,在新增管理員時用戶名不能讓他重複,否則就要解決重複的問題

基於菜單

這個其實和角色沒啥區別
主要是在登錄查詢時,把菜單的路由也拿到,然後返回給前端,前端只展示該角色下的路由就行了。思路就如此了。代碼就不展示了,畢竟只是查詢多了一點(我沒寫連表查詢的。記錄不想去寫)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章