引言

在Web应用开发中,安全一直是非常重要的一个方面。Spring Security基于Spring 框架,提供了一套Web应用安全性的完整解决方案。

JwT (JSON Web Token) 是当前比较主源的Token令牌生成方案,非常适合作为登录和授权认证的凭证。

这里我们就使用 Spring Security并结合JWT实现用户认证(Authentication) 和用户授权(Authorization) 两个主要部分的安全内容。

一、JWT与OAuth2的区别

在此之前,只是停留在用的阶段,对二者的使用场景很是模糊,感觉都是一样的呀,有啥不同呢,这里我也是根据网上的指点,在这罗列一下。

1、跨域实现不同

首先是涉及到跨域的问题:

如果ABC三个系统是相同域名的,比如都是www.a.com,那么就可以使用JWT的方式,将三个系统改造成统一的一个登录和拦截校验。

如果ABC不是相同域名的,比如:

www. a.com,www.b.com,www.c.com,建议不要使用JWT这种方式,因为需要涉及到跨域,这样跨域获取token可能存在安全问题,可以考虑使用传统cookie+session方式来实现跨域的SSO机制。或者可以使用SpringBoot+Security+OAuth2来实现,这就涉及到了OAuth2了.

2、所属性质原理不同

OAuth2是一种授权框架

OAuth2是一种授权框架,提供了一套详细的授权机制(指导)。用户或应用可以通过公开的或私有的设置,授权第三方应用访问特定资源。

JWT是一种认证协议

JWT提供了一种用于发布接入令牌(Access Token),并对发布的签名接入令牌进行验证的方法。 令牌(Token)本身包含了一系列声明,应用程序可以根据这些声明限制用户对资源的访问。

3、应用场景不同

OAuth2用在使用第三方账号登录的情况(比如使用weibo, qq, github登录某个app)

JWT是用在前后端分离, 需要简单的对后台API进行保护时使用.(前后端分离无session, 频繁传用户密码不安全)

OAuth2是一个相对复杂的协议, 有4种授权模式, 其中的access code模式在实现时可以使用jwt才生成code, 也可以不用. 它们之间没有必然的联系;

OAuth2有client和scope的概念,jwt没有。

如果只是拿来用于颁布token的话,二者没区别,常用的bearer算法oauth、jwt都可以用,只是应用场景不同而已。

具体的比较推荐给大家一片文章,写的很详细


二、正题Spring Security并结合JWT实现用户认证和用户授权

2.1、添加pom.xml依赖

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-security</artifactId>

</dependency>

<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt</artifactId>

<version>0.9.1</version>

</dependency>

1

2

3

4

5

6

7

8

9

2.2、添加配置文件WebSecurityConfig

在config 包下新建一个Spring Security 的配置类WebSecurityConfig, 主要是进行一些安全相关的配置,比如权限URL匹配策略、认证过滤器配置、定制身份验证组件、开启权限认证注解等,具体代码作用参见代码注释。

/**

* @program: mangocms

* @description: 安全配置类

* @author: zjc

* @create: 2020-08-05 19:53

**/

@Configuration

@EnableWebSecurity //开启Spring Security

@EnableGlobalMethodSecurity(prePostEnabled = true) //开启权限注解 例如:@PreAuthorize注解

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired

private UserDetailsService userDetailsService;

@Override

protected void configure(AuthenticationManagerBuilder auth) throws Exception {

//使用自定义身份认证组件

auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));

}

@Override

protected void configure(HttpSecurity http) throws Exception {

//禁用csrf,由于使用的是jwt,我们这里不需要csrf

http.cors().and().csrf().disable().authorizeRequests()

//跨域预检请求

.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()

//web jars

.antMatchers("/webjars/**").permitAll()

//查看SQL监控(druid)

.antMatchers("/druid/**").permitAll()

//首页和登录页面

.antMatchers("/").permitAll().antMatchers("/login").permitAll()

//swagger

.antMatchers("/swagger-ui.html").permitAll()

.antMatchers("/swagger-resources/**").permitAll()

.antMatchers("/v2/api-docs").permitAll()

.antMatchers("/webjars/springfox-swagger-ui/**").permitAll()

//验证码

.antMatchers("/captcha.jpg**").permitAll()

//服务监控

.antMatchers("/actuator/**").permitAll()

//其他所有请求需要身份验证

.anyRequest().authenticated();

//退出登录处理器

http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());

//token验证过滤器

http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()),UsernamePasswordAuthenticationFilter.class);

}

@Bean

@Override

public AuthenticationManager authenticationManager() throws Exception{

return super.authenticationManager();

}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

2.3、JwtAurthenticationFilter登录认证过滤器

登录认证过滤器负责登录认证时检查并生产令牌并保存到上下文,接口权限认证过程时,系统从上下文获取令牌校验接口访问权限,新建一个security包,在其下创建JwtAurthenticationFilter并继承BasicAuthenticationFilter, 覆写其中的doFilterlntermal 方法进行Token校验。

/**

* @program: mangocms

* @description: 登录认证过滤器

* @author: zjc

* @create: 2020-08-05 20:53

**/

@Configuration

public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

@Autowired

public JwtAuthenticationFilter(AuthenticationManager authenticationManager){

super(authenticationManager);

}

@Override

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

//获取token,并检查登陆状态,检查request中的请求信息

SecurityUtils.checkAuthentication(request);

chain.doFilter(request,response);

}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

这里我们把验证逻辑抽取到了SecurityUtils 的checkAuthentication 方法中,checkAuthentication通过JwtTokenUtils的方法获取认证信息并保存到Spring Security上下文中。

2.4、SecurityUtils获取令牌并进行认证

/**

* 获取令牌进行认证

*/

public static void checkAuthentication(HttpServletRequest request){

//获取令牌并根据令牌获取登录认证信息

Authentication authentication =JwtTokenUtils.getAuthentticattionFromToken(request);

//设置登录认证信息到上下文

SecurityContextHolder.getContext().setAuthentication(authentication);

}

1

2

3

4

5

6

7

8

9

2.5、JwtTokenUtils根据请求令牌获取登录认证信息

这里只是再进行主要方法的逻辑追踪,后面我会附上完整的代码下载地址。

/**

* 根据请求令牌获取登录认证信息

*

*/

public static Authentication getAuthentticattionFromToken(HttpServletRequest request){

Authentication authentication =null;

//获取请求携带的令牌

String token = JwtTokenUtils.getToken(request);

if (token != null){

//请求令牌并不能为空

if(SecurityUtils.getAuthentication() == null){

//上下文中的Authentication

Claims claims = getClaimsFromToken(token);

if(claims == null){

return null;

}

String username =claims.getSubject();

if(username == null){

return null;

}

if(isTokenExpired(token)){

return null;

}

Object authors =claims.get(AUTHORITIES);

List<GrantedAuthority> authorities =new ArrayList<>();

if(authors != null && authors instanceof List){

for(Object object : (List) authors){

authorities.add(new GrantedAuthorityImpl(

(String)((Map) object).get("authority")));

}

}

authentication = new JwtAuthenticatioToken(username,null,authorities,token);

}else{

if(validateToken(token,SecurityUtils.getUsername())){

//如果上下文中Authentication非空,且请求命令合法

//直接返回当前登录认证信息

authentication = SecurityUtils.getAuthentication();

}

}

}

return authentication;

}


/**

* 获取请求Token

* @param request

* @return

*/

//尝试从请求头中获取请求写带的令牌,默认从请求头中的“Authentication”参数以“Bearer”开头的信息为令牌信息,

//若为空的话,尝试从token参数获取

public static String getToken(HttpServletRequest request){

String token = request.getHeader("Authorization");

String tokenHead = "Bearer";

if(token == null){

token = request.getHeader("token");

}else if(token.contains(tokenHead)){

//当且仅当此字符串包含指定的tokenHead值序列时,返回true。

token =token.substring(tokenHead.length());

}

if("".equals(token)){

token = null;

}

return token;

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

2.6身份认证组件

SpringSecurity 的登录验证是交由ProviderManager负责的,ProviderManager 在实际验证时其通过调用AuthenticationProvider的authenticate方法来进行认证。数据库类型的默认实现方案是DaoAuthenticationProvider。我们这里通过继承DaoAuthenticationProvider 定制默认的登录认证逻辑,在Security 包下新建验证器JwtAuthenticationProvider并继承DaoAuthenicationProvider,覆盖实现additionalAuthenticationChecks方法进行密码匹配,我们这里没有使用默认的密码认证器 (我们使用盐salt来对密码加密,默认密码验证器没有加盐),所以这里定制了自己的密码校验逻辑, 当然你也可以通过直接覆写authenticate方法来完成更大范围的登录认证需求定制。

JwtAuthenticationProvider身份验证提供者

/**

* @program: mangocms

* @description: 身份认证提供者

* @author: zjc

* @create: 2020-08-08 09:35

**/

public class JwtAuthenticationProvider extends DaoAuthenticationProvider {

public JwtAuthenticationProvider(UserDetailsService userDetailsService)

{

setUserDetailsService(userDetailsService);

}

@Override

protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

if(authentication.getCredentials() == null){

logger.debug("Authentraction failed: no credentials provided");

throw new BadCredentialsException(messages.getMessage("AbstractUserDetalisAuthenticationProvider.badCredentials","Bad credentials"));

}

String presentedPassword = authentication.getCredentials().toString();

String salt = ((JwtUserDetails)userDetails).getSalt();

if(! new PasswordEncoder(salt).matches(userDetails.getPassword(),presentedPassword)){

//覆写密码验证逻辑 matches:匹配两者

logger.debug("Authentication failed: password does not match");

throw new BadCredentialsException(messages.getMessage(

"AbstractUserDetailsAuthenticationProvider.badCredentials",

"Bad credentials"

));

}

}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

2.7、认证信息查询

我们上面提到登录认证默认是通过DaoAuthenticationProvider来完成登录认证的,而我们知道登录验证器在进行时肯定是要从数据库获取用户信息进行匹配的,而这个获取用户信息的任务是通过Spring Security的UserDetailsService组件来完成的。

在security包下新建一个UserDetailsServiceImpl并实现UserDetailsService接口,覆写其中的方法lodUserByUsermame,查询用户的密码信息和权限信息并封装到UseDetailis的实现类对象,作为结果JwtUserDetails返回给DaoAuthenticationProvider做进一步处理。

新建一个UserDetailsServiceImpl并实现UserDetailsService接口

/**

* @program: mangocms

* @description: UserDetails实现类

* @author: zjc

* @create: 2020-08-08 10:20

**/

@Service

public class UserDetailsServiceImpl implements UserDetailsService {

@Autowired

private UserService userService;

@Override

public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

User user = userService.findByName(username);

if (user == null){

throw new UsernameNotFoundException("该用户不存在");

}

//用户权限列表,根据权限标志如@PreAuthorize("hasAuthority('sys:menu:view'))

//标注的接口对比,决定是否可以调用该接口

Set<String> permissions = userService.findPermissions(user.getName());

List<GrantedAuthority> grantedAuthorities = permissions.stream().map(GrantedAuthorityImpl::new).collect(Collectors.toList());

return new JwtUserDetails(user.getName(),user.getPassword(),user.getSalt(),grantedAuthorities);

}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

创建安全用户模型JwtUserDetails

/**

* @program: mangocms

* @description: 安全用户模型

* @author: zjc

* @create: 2020-08-08 10:42

**/

public class JwtUserDetails implements UserDetails {

private String username;

private String password;

private String salt;

private Collection<? extends GrantedAuthority> authorities;

public JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) {

this.username = username;

this.password = password;

this.salt = salt;

this.authorities = authorities;

}

//忽略set/get

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

GrantedAuthorityimpl实现Spring Security的GrantedAuthority接口,是对权限的封装,内部包含一个字符串类类的权限标识authority,对应菜单表的perms字段的权限字符串,比如用户管理的增删改查权限标志sys:user:view、 sys:user:add、 sys:user:edit、 sys:user:delete等

/**

* @program: mangocms

* @description: 权限封装

* @author: zjc

* @create: 2020-08-08 10:46

**/

public class GrantedAuthorityImpl implements GrantedAuthority {

private static final long serialVersionUID = 1L;

private String authority;

public GrantedAuthorityImpl(String authority) {

this.authority = authority;

}

public void setAuthority(String authority){

this.authority = authority;

}

@Override

public String getAuthority() {

return this.getAuthority();

}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

2.8、添加权限注解

在用户拥有某个后台接口访问权限的时候才能访问,这叫作接口保护。我们这里就通过Spring Security提供的权限注解来保护后台接口免受非法访问,这里以字典管理模块为例,其他模块同理。

在SysDictController 的接口上添加类似@PreAuthorize("hasAuthority(sysdictwiew/)的注解,表示只有当前登录用户拥有sys:dict:view权限标识才能访向此按口,这里的权限标识需对应菜单表中的perms权限信息,所以可以通过配置菜单表的权限来灵活控制接口的访问权限。

/**

* 根据名称查询

*

* @param lable

* @return

*/

@PreAuthorize("hasAuthority('sys:dict:view')")

@PostMapping(value = "/findByLable")

@ApiOperation(value ="根据名称查询",httpMethod = "POST",response = HttpResult.class,notes = "按名称查询")

public HttpResult findByLable(@RequestParam String lable) {

return HttpResult.ok(dictService.findByLable(lable));

}

1

2

3

4

5

6

7

8

9

10

11

12

13

2.9、为Swagger添加令牌参数

由于我们引入Spring Securitry安全框架,接口受到保护,需要携带合法的token令牌,就是登录成功之后由后台返回才能正常访问,但是Swagger本身的接口测试页面默认并没有传token参数的地方,因此需要简单定制一下,修改SwaggerConfig配类即可。

/**

* @program: mango

* @description: swagger配置类

* @author: zjc

* @create: 2020-07-18 09:51

**/

@Configuration

@EnableSwagger2

public class SwaggerConfig {

/**

* 创建API应用

* apiInfo() 增加API相关信息

* 通过select()函数返回一个ApiSelectorBuilder实例,用来控制哪些接口暴露给Swagger来展现,

* 本例采用指定扫描的包路径来定义指定要建立API的目录。

*

* @return

*/

@Bean

public Docket createRestApi() {

//添加请求参数,我们这里吧token作为请求头部参数传入后端

/* ParameterBuilder parameterBuilder = new ParameterBuilder();

List<Parameter> parameters = new ArrayList<>();

parameterBuilder.name("token").description("令牌").modelRef(new ModelRef("string")).parameterType("header").required(false).build();

parameters.add(parameterBuilder.build());*/

ParameterBuilder ticketPar = new ParameterBuilder();

List<Parameter> pars = new ArrayList<Parameter>();

ticketPar.name("token").description("user ticket")//Token 以及Authorization 为自定义的参数,session保存的名字是哪个就可以写成那个

.modelRef(new ModelRef("string")).parameterType("header")

.required(false).build(); //header中的ticket参数非必填,传空也可以

pars.add(ticketPar.build()); //根据每个方法名也知道当前方法在设置什么参数

return new

Docket(DocumentationType.SWAGGER_2)

.apiInfo(apiInfo())

.select()

.apis(RequestHandlerSelectors.any())

.paths(PathSelectors.any())

.build()

.globalOperationParameters(pars);

}

/**

* 创建该API的基本信息(这些基本信息会展现在文档页面中)

* 访问地址:http://项目实际地址/swagger-ui.html

*

* @return

*/

private ApiInfo apiInfo() {

return new ApiInfoBuilder()

.title("ManGo接口页面")

.description("所有接口信息")

.version("1.0")

.build();

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

这回我们在Swagger界面就能够看到输入令牌的地方了。

三、登录接口实现

在登录控制器中添加一个登录接口login, 在其中验证验证码、用户名、密码信息。匹配成功之后,执行Spring Security的登录认证机制。登录成功之后,返回Token令牌凭证。

/**

* 登录入口

* @param loginBean

* @param request

* @return

* @throws Exception

*/

@PostMapping(value = "/login")

public HttpResult login(@RequestBody LoginBean loginBean, HttpServletRequest request)throws Exception{

String username = loginBean.getAccount();

String password = loginBean.getPassword();

String captcha = loginBean.getCaptcha();

//从session中获取之前保存的验证码,跟前台传过来的验证码进行匹配

Object kaptcha = request.getSession().getAttribute(Constants.KAPTCHA_SESSION_KEY);

if(kaptcha == null){

return HttpResult.error("验证码已失效");

}

if(!captcha.equals(kaptcha)){

return HttpResult.error("验证码不正确");

}

//用户信息

User user = userService.findByName(username);

if(user == null){

return HttpResult.error("帐号不存在");

}

if (!PasswordUtils.matches(user.getSalt(),password,user.getPassword())){

return HttpResult.error("密码不正确");

}

//账号锁定

if(user.getStatus() == 0){

return HttpResult.error("帐号已被锁定,请联系管理员");

}

//系统登陆认证

JwtAuthenticatioToken token = SecurityUtils.login(request,username,password,authenticationManager);

return HttpResult.ok(token);

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

我们这里将Spring Security的登录认证逻辑封装到了工具类SecurityUtils的login方法中,认证流程大致分为下面4个步骤:

(1)将用户名密码的认证信息封装到JwtAuthenticatioToken 对象。

(2)通过调用authenticationManager.authenticate(token)执行认证流程。

(3)通过SecurityContextHolder将认证信息保存到Security上下文。

(4)通过JwtTokenUtils generateToken(authentication)生成token并返回。

具体过程详见代码。

/**

* 系统登录认证

* @param request

* @param username

* @param password

* @param authenticationManager

* @return

*/

public static JwtAuthenticatioToken login(HttpServletRequest request, String username, String password, AuthenticationManager authenticationManager) {

JwtAuthenticatioToken token = new JwtAuthenticatioToken(username, password);

token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// 执行登录认证过程

Authentication authentication = authenticationManager.authenticate(token);

// 认证成功存储认证信息到上下文

SecurityContextHolder.getContext().setAuthentication(authentication);

// 生成令牌并返回给客户端

token.setToken(JwtTokenUtils.generateToken(authentication));

return token;

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

JwtTokenUtils生成Token的逻辑

/**

* 生成令牌

*/

public static String generateToken(Authentication authentication) {

Map<String, Object> claims = new HashMap<>(3);

claims.put(USERNAME, SecurityUtils.getUsername(authentication));

claims.put(CREATED, new Date());

claims.put(AUTHORITIES, authentication.getAuthorities());

return generateToken(claims);

}

/**

* 从数据声明生成令牌

*

* @param claims 数据声明

* @return 令牌

*/

private static String generateToken(Map<String, Object> claims) {

Date expirationDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);

return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, SECRET).compact();

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

登陆实体LogginBean

public class LoginBean {

private String account;

private String password;

private String captcha;

//省略set/get

}

1

2

3

4

5

6

JwtAuthenticatioToken继承UsemamePasswordAuthenticationToken,是对令牌信息收封装,用来作为认证和授权的信任凭证,其中的token信息由JWT负贵生成。

JwtAuthenticatioToken

package com.mango.cms.mangomain.security;

import java.util.Collection;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

import org.springframework.security.core.GrantedAuthority;

/**

* 自定义令牌对象

*

*/

public class JwtAuthenticatioToken extends UsernamePasswordAuthenticationToken {

private static final long serialVersionUID = 1L;


private String token;

public JwtAuthenticatioToken(Object principal, Object credentials){

super(principal, credentials);

}


public JwtAuthenticatioToken(Object principal, Object credentials, String token){

super(principal, credentials);

this.token = token;

}

public JwtAuthenticatioToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities, String token) {

super(principal, credentials, authorities);

this.token = token;

}


public String getToken() {

return token;

}

public void setToken(String token) {

this.token = token;

}

public static long getSerialversionuid() {

return serialVersionUID;

}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

四、测试

接下来就可以运行项目进行测试了,我们点击验证码接口,将生成的验证码在输入到登录的接口中。

 

这里我数据库中本是没有数据的,我只是简单添加了明文密码,所以提示密码错误,但这不影响我们代码的使用。

我已将源码结构传到下载界面,大家有需要的可以直接使用。下载地址:

SpringSecurity之JWT实现token认证和授权.zip