JWT实现SSO单点登录
in JavaDevelop with 0 comment

JWT实现SSO单点登录

in JavaDevelop with 0 comment

令牌配置

接下来的内容是:

token 的处理在认证服务器处理的。之前已经配置了资源服务器,现在来自定义认证服务器

spirng boot 2 的自动配置文件和1.5的不一样
直接跟着视频走是不会成功的,原因如下

OAuth2AuthorizationServerConfiguration 类是 @EnableAuthorizationServer 的自动配置类;
如果我们 继承了 AuthorizationServerConfigurerAdapter,那么该类将不会被初始化,认证服务器将不能正常工作
(看源码中的条件注解声明得知)

这是根据自动配置类简化而来的配置。正常使用

package cn.mrcode.imooc.springsecurity.securityapp;

/**
 * ${desc}
 * @author zhuqiang
 * @version 1.0.1 2018/8/7 10:52
 * @date 2018/8/7 10:52
 * @since 1.0
 */
@Configuration
@EnableAuthorizationServer
public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    private final AuthenticationManager authenticationManager;
//    @Autowired
//    private PasswordEncoder passwordEncoder;

    public MyAuthorizationServerConfig(
            AuthenticationConfiguration authenticationConfiguration) throws Exception {
        this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("myid")
                .secret("myid")
                .redirectUris("http://example.com", "http://ora.com")
                .and()
                .withClient("myid2")
                .secret("myid2")
                .redirectUris("http://example.com", "localhost:8080")
                .authorizedGrantTypes("refresh_token", "password")
                .accessTokenValiditySeconds(7200)
                .scopes("all", "read", "write");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(this.authenticationManager);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 这里使用什么密码需要 根据上面配置client信息里面的密码类型决定
        // 目前上面配置的是无加密的密码
        security.passwordEncoder(NoOpPasswordEncoder.getInstance());
    }
}

抽成配置

下面记录下一些注意的地方

yml中数组和对象嵌套的写法如下:

imooc:
  security:
    oauth2:
      clients:
        -
          clientId: myid
          clientSecret: myid
          redirectUris:
            - "http://example.com"
            - "http://ora.com"
          accessTokenValiditySeconds: 0
        -
          clientId: myid2
          clientSecret: myid2
          authorizedGrantTypes: ["refresh_token", "password"]
          redirectUris:
            - "http://example.com"
            - "localhost:8080"
          scopes: ["all", "read", "write"]
          accessTokenValiditySeconds: 7200

配置类对应,注意看下面的数组。都是默认为空数组,这样不会导致代码中npe

public class OAuth2Properties {
    private OAuth2ClientProperties[] clients = {};

public class OAuth2ClientProperties {
    private String clientId;
    private String clientSecret;
    private String[] authorizedGrantTypes = {};
    private String[] redirectUris = {}; // 信任的回调域
    private String[] scopes = {};
    private int accessTokenValiditySeconds; // token有效期

配置使用的地方

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    InMemoryClientDetailsServiceBuilder inMemory = clients.inMemory();
    OAuth2ClientProperties[] clientsInCustom = securityProperties.getOauth2().getClients();
    for (OAuth2ClientProperties p : clientsInCustom) {
        inMemory.withClient(p.getClientId())
                .secret(p.getClientSecret())
                .redirectUris(p.getRedirectUris())
                .authorizedGrantTypes(p.getAuthorizedGrantTypes())
                .accessTokenValiditySeconds(p.getAccessTokenValiditySeconds())
                .scopes(p.getScopes());
    }
    logger.info(Arrays.toString(clientsInCustom));
}

tokenStore 使用redis来存储

上面的配置都是使用的内存来存储令牌信息;令牌的存储和获取比较频繁,为了能持久化。使用redis

在认证服务配置类中配置tokenStore即可

cn.mrcode.imooc.springsecurity.securityapp.MyAuthorizationServerConfig

@Autowired(required = false)
public TokenStore tokenStore;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints.authenticationManager(this.authenticationManager);
    endpoints.tokenStore(tokenStore);
}

TokenStore 需要使用的地方初始化对象,也就是app中

package cn.mrcode.imooc.springsecurity.securityapp;

import cn.mrcode.imooc.springsecurity.securitycore.MyRedisTokenStore;

@Configuration
public class TokenStoreConfig {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        return new MyRedisTokenStore(redisConnectionFactory);
    }
}

注意在spring boot 2.0.4 中;使用默认的 RedisTokenStore 在存储的时候会出现异常;

解决方案:把RedisTokenStore的代码完全copy一份,然后把 storeAccessToken 方法中调用
conn.set的代码全部缓存 conn.stringCommands().set

@Override
   public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
       byte[] serializedAccessToken = serialize(token);
       byte[] serializedAuth = serialize(authentication);
       byte[] accessKey = serializeKey(ACCESS + token.getValue());
       byte[] authKey = serializeKey(AUTH + token.getValue());
       byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication));
       byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
       byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());

       RedisConnection conn = getConnection();
       try {
           conn.openPipeline();
           // 这里 set的时候,子类都不支持存储自己数组
           conn.stringCommands().set(accessKey, serializedAccessToken);

一篇不错的redis的配置文章:https://majing.io/posts/10000020931206
切换到jedis中还是会出现一样的错误,所以不用测试jedis了;就是依赖包不兼容的问题,

使用JWT替换默认令牌

什么是jwt?

JWT是json web token缩写。它将用户信息加密到token里,服务器不保存任何用户信息。服务器通过使用保存的密钥验证token的正确性,只要正确即通过验证。

优点:在分布式系统中,很好地解决了单点登录问题,很容易解决了session共享的问题。

缺点:是无法作废已颁布的令牌/不易应对数据过期。

特点:

  1. 自包含 : 包含自定义信息
  2. 密签:使用指定密钥签名,防止串改,不是防止破解
  3. 可扩展:也就是自定义业务信息

配置jwt

主要是增加了JwtTokenConfig类;
为了能方便切换,使用了 @ConditionalOnProperty注解;

package cn.mrcode.imooc.springsecurity.securityapp;

@Configuration
public class TokenStoreConfig {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    @ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "redis")
    public TokenStore tokenStore() {
        return new MyRedisTokenStore(redisConnectionFactory);
    }


    @Configuration
    // matchIfMissing :当tokenStore没有值的时候是否生效
    // 当tokenStore = jwt的时候或则tokenStore没有配置的时候使用下面的配置
    @ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "jwt", matchIfMissing = true)
    public static class JwtTokenConfig {
        @Autowired
        private SecurityProperties securityProperties;
        @Autowired
        private JwtAccessTokenConverter jwtAccessTokenConverter;

        @Bean
        public TokenStore jwtTokenStore() {
            return new JwtTokenStore(jwtAccessTokenConverter);
        }

        @Bean
        public JwtAccessTokenConverter jwtAccessTokenConverter() {
            JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            converter.setSigningKey(securityProperties.getOauth2().getJwtSigningKey());  // 设置密钥
            return converter;
        }
    }
}

认证服务器还需要配置 JwtAccessTokenConverter

@Autowired(required = false)
// 只有当使用jwt的时候才会有该对象
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints.authenticationManager(this.authenticationManager);
    endpoints.tokenStore(tokenStore);
    if (jwtAccessTokenConverter != null) {
        endpoints.accessTokenConverter(jwtAccessTokenConverter);
    }
}

获取token:获取方式不变

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp0aSI6IjkwYjQ4MTkxLTcwNGEtNDUxZS04NzkyLTk2NWMxYjNhMGMyMiIsImNsaWVudF9pZCI6Im15aWQiLCJzY29wZSI6WyJhbGwiXX0.bIe7RmyEaKdi8aX5C7JwaRq68m1WfpSXMvPZkjjoSus",
    "token_type": "bearer",
    "scope": "all",
    "jti": "90b48191-704a-451e-8792-965c1b3a0c22"
}

一个在线解码网址:http://jwt.calebb.net/ ; 把上面的access_token放进去

{
 alg: "HS256",
 typ: "JWT"
}.
{
 user_name: "admin",
 jti: "90b48191-704a-451e-8792-965c1b3a0c22",
 client_id: "myid",
 scope: [
  "all"
 ]
}.
[signature]

访问;只是把之前的accessToken换成了jwt的accessToken信息

GET /user/me HTTP/1.1
Host: localhost:8080
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp0aSI6IjkwYjQ4MTkxLTcwNGEtNDUxZS04NzkyLTk2NWMxYjNhMGMyMiIsImNsaWVudF9pZCI6Im15aWQiLCJzY29wZSI6WyJhbGwiXX0.bIe7RmyEaKdi8aX5C7JwaRq68m1WfpSXMvPZkjjoSus

TokenEnhancer jwt增强

因为Token的产生是框架流程出来的。我们如果需要在jwt生成之前对其修改;只能使用TokenEnhancer;

来源查看源码:DefaultTokenServices#createAccessToken中调用了TokenEnhancer

认证服务器配置,增加tokenEnhancer

cn.mrcode.imooc.springsecurity.securityapp.MyAuthorizationServerConfig

    @Autowired(required = false)
   // 只有当使用jwt的时候才会有该对象
   private JwtAccessTokenConverter jwtAccessTokenConverter;
   /**
    * @see TokenStoreConfig
    */
   @Autowired(required = false)
   private TokenEnhancer jwtTokenEnhancer;


    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(this.authenticationManager);
        endpoints.tokenStore(tokenStore);
        /**
         * 私有方法,但是在里面调用了accessTokenEnhancer.enhance所以这里使用链
         * @see DefaultTokenServices#createAccessToken(org.springframework.security.oauth2.provider.OAuth2Authentication, org.springframework.security.oauth2.common.OAuth2RefreshToken)
         */
        if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
            TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
            List<TokenEnhancer> enhancers = new ArrayList<>();
            enhancers.add(jwtTokenEnhancer);
            enhancers.add(jwtAccessTokenConverter);
            enhancerChain.setTokenEnhancers(enhancers);
            // 一个处理链,先添加,再转换
            endpoints
                    .tokenEnhancer(enhancerChain)
                    .accessTokenConverter(jwtAccessTokenConverter);
        }
    }

TokenEnhancer 配置和自定义实现

package cn.mrcode.imooc.springsecurity.securityapp;

/**
 * @author : zhuqiang
 * @version : V1.0
 * @date : 2018/8/11 15:56
 */
@Configuration
public class TokenStoreConfig {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    @ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "redis")
    public TokenStore tokenStore() {
        return new MyRedisTokenStore(redisConnectionFactory);
    }

    @Configuration
    // matchIfMissing :当tokenStore没有值的时候是否生效
    // 当tokenStore = jwt的时候或则tokenStore没有配置的时候使用下面的配置
    @ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "jwt", matchIfMissing = true)
    public static class JwtTokenConfig {
        @Autowired
        private SecurityProperties securityProperties;

        @Bean
        public TokenStore jwtTokenStore() {
            return new JwtTokenStore(jwtAccessTokenConverter());
        }

        @Bean
        public JwtAccessTokenConverter jwtAccessTokenConverter() {
            JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            converter.setSigningKey(securityProperties.getOauth2().getJwtSigningKey());  // 设置密钥
            return converter;
        }

        @Bean
        // 不能使用该条件注解,因为JwtAccessTokenConverter也是一个TokenEnhancer
//        @ConditionalOnMissingBean(TokenEnhancer.class)
        // 而 ConditionalOnBean 是必须存在一个TokenEnhancer的时候,才被创建
        // 先不纠结这个问题了。就这样吧。也就是封装程度的问题
        @ConditionalOnBean(TokenEnhancer.class)
        public TokenEnhancer jwtTokenEnhancer() {
            return new ImoocJwtTokenEnhancer();
        }
    }
}

自定义增强器的实现

/**
 *
 */
package cn.mrcode.imooc.springsecurity.securityapp.jwt;

import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;

import java.util.HashMap;
import java.util.Map;

/**
 * @author zhailiang
 *
 */
public class ImoocJwtTokenEnhancer implements TokenEnhancer {
	@Override
	public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
		Map<String, Object> info = new HashMap<>();
    // 需要增加的信息
    // 所以如果是需要动态的话,只能在该方法中去调用业务方法添加动态参数信息
		info.put("company", "imooc");

		// 设置附加信息
		((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(info);

		return accessToken;
	}
}

被解码后的值

{
  alg: "HS256",
  typ: "JWT"
}.
{
  company: "imooc",    // 这里
  user_name: "admin",
  jti: "dcc5f820-e06f-4626-abc2-02e9cf7df8f8",
  client_id: "myid",
  scope: [
    "all"
  ]
}.
[signature]

这里有一个问题,spring的授权用户信息里面没有自定义的字段,所以要通过jwt的accessToken
获取到自定义信息的话,还需要自己来解析

可以跟下源码:解码是在这里

org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter#decode

上面的代码能解析出自定义的信息,但是用户信息被 org.springframework.security.oauth2.provider.OAuth2Authentication 类最后返回的
。该类中没有自定义信息的字段。所以自定义信息丢失了

解析

security-demo/build.gradle 增加依赖;
为什么在demo里面?因为和上面分析的一致,公用的只管通用的,业务的自己处理

// ~ jwt==========================
compile 'io.jsonwebtoken:jjwt:0.9.1'

控制器中解析token信息

/**
 * 下面有几种获取方法,可以查看类里面的信息
 * @param userDetails
 * @param authentication
 * @param request
 * @return
 */
@GetMapping("/me")
public Object getCurrentUser(@AuthenticationPrincipal UserDetails userDetails, Authentication authentication, HttpServletRequest request) throws UnsupportedEncodingException {
//        Authentication authentication1 = SecurityContextHolder.getContext().getAuthentication();
    // Authorization : bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21wYW55IjoiaW1vb2MiLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp0aSI6ImRjYzVmODIwLWUwNmYtNDYyNi1hYmMyLTAyZTljZjdkZjhmOCIsImNsaWVudF9pZCI6Im15aWQiLCJzY29wZSI6WyJhbGwiXX0.nYFBXcLBN3WNef0sooNxS0s6CaEleDGfjZh7xtTEqf4
    // 增加了jwt之后,获取传递过来的token
    // 当然这里只是其中一种的 token的传递方法,自己要根据具体情况分析
    String authorization = request.getHeader("Authorization");
    String token = StringUtils.substringAfter(authorization, "bearer ");
    logger.info("jwt token", token);
    String jwtSigningKey = securityProperties.getOauth2().getJwtSigningKey();
    // 生成的时候使用的是 org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter
    // 源码里面把signingkey变成utf8了
    // JwtAccessTokenConverter类,解析出来是一个map
    // 所以这个自带的JwtAccessTokenConverter对象也是可以直接用来解析的
    byte[] bytes = jwtSigningKey.getBytes("utf-8");
    Claims body = Jwts.parser().setSigningKey(bytes).parseClaimsJws(token).getBody();

    return body;
}

#JWT
JWT = JSON Web Token,它是通过JSON格式组织必要的数据,将数据记录在票据(Token)上,并且,结合一定的算法,使得这些数据会被加密,然后在网络上传输,服务器端收到此数据后,会先对此数据进行解密,从而得到票据上记录的数据(JSON数据),从而识别用户的身份,或者处理相关的数据。

其实,在客户端第1次访问服务器端时,是“空着手”访问的,不会携带任何票据数据,当服务器进行响应时,会将JWT响应到客户端,客户端从第2次访问开始,每次都应该携带JWT发起请求,则服务器都会收到请求中的JWT并进行处理。

要使用JWT,需要添加相关的依赖项,可以实现生成JWT、解析JWT的框架较多,目前,主流的JWT框架可以是jjwt

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

则在根项目中管理以上依赖,并在csmall-passport中添加以上依赖。

测试使用JWT:

package cn.tedu.csmall.passport;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JwtTests {

    // 密钥
    String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";

    @Test
    public void testGenerateJwt() {
        // Claims
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("name", "星星");

        // JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
        String jwt = Jwts.builder()
                // Header:指定算法与当前数据类型
                // 格式为: { "alg": 算法, "typ": "jwt" }
                .setHeaderParam(Header.CONTENT_TYPE, "HS256")
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                // Payload:通常包含Claims(自定义数据)和过期时间
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
                // Signature:由算法和密钥(secret key)这2部分组成
                .signWith(SignatureAlgorithm.HS256, secretKey)
                // 打包生成
                .compact();

        // eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NTY3N30.QwBYVgdkdibEpD-pjX4sKfNu3tw8hBLcJy4-UcN1F3c
        // eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQg
        System.out.println(jwt);
    }

    @Test
    public void testParseJwt() {
        String jwt = "eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQg";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object id = claims.get("id");
        Object name = claims.get("name");
        System.out.println("id=" + id);
        System.out.println("name=" + name);
    }

}

当JWT数据过期时,异常信息例如:

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-06-16T15:47:57Z. Current time: 2022-06-16T16:08:32Z, a difference of 1235869 milliseconds.  Allowed clock skew: 0 milliseconds.

当JWT解析失败(数据有误)时,异常信息例如:

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"cty","HS256","typ":"JWT","alg":"HS256"}

当生成JWT和解析JWT的密钥不一致时,异常信息例如:

io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

要在Spring Security中使用JWT,至少需要:

相关代码:SecurityConfiguration

package cn.tedu.csmall.passport.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用防跨域攻击
        http.csrf().disable();

        // URL白名单
        String[] urls = {
            "/admins/login"
        };

        // 配置各请求路径的认证与授权
        http.authorizeRequests() // 请求需要授权才可以访问
            .antMatchers(urls) // 匹配一些路径
            .permitAll() // 允许直接访问(不需要经过认证和授权)
            .anyRequest() // 匹配除了以上配置的其它请求
            .authenticated(); // 都需要认证
    }
}

相关代码:AdminLoginDTO

package cn.tedu.csmall.pojo.dto;

import lombok.Data;

import java.io.Serializable;

@Data
public class AdminLoginDTO implements Serializable {

    private String username;
    private String password;

}

相关代码:IAdminService

package cn.tedu.csmall.passport.service;

import cn.tedu.csmall.pojo.dto.AdminLoginDTO;

public interface IAdminService {

    String login(AdminLoginDTO adminLoginDTO);

}

相关代码:AdminServiceImpl

package cn.tedu.csmall.passport.service;

import cn.tedu.csmall.pojo.dto.AdminLoginDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Service
public class AdminServiceImpl implements IAdminService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public String login(AdminLoginDTO adminLoginDTO) {
        // 准备被认证数据
        Authentication authentication
                = new UsernamePasswordAuthenticationToken(
                        adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
        // 调用AuthenticationManager验证用户名与密码
        // 执行认证,如果此过程没有抛出异常,则表示认证通过,如果认证信息有误,将抛出异常
        authenticationManager.authenticate(authentication);

        // 如果程序可以执行到此处,则表示登录成功
        // 生成此用户数据的JWT
        String jwt = "This is a JWT."; // 临时
        return jwt;
    }

}

相关代码:AdminController

package cn.tedu.csmall.passport.controller;

import cn.tedu.csmall.passport.service.IAdminService;
import cn.tedu.csmall.pojo.dto.AdminLoginDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")
public class AdminController {

    @Autowired
    private IAdminService adminService;

    // http://localhost:8080/admins/login?username=root&password=123456
    @RequestMapping("/login")
    public String login(AdminLoginDTO adminLoginDTO) {
        String jwt = adminService.login(adminLoginDTO);
        return jwt;
    }

}

以上全部完成后,启动项目,打开浏览器,可以通过 http://localhost:8080/admins/login?username=root&password=123456 这类URL测试登录,使用数据库中的用户名和密码进行尝试。

当通过以上URL进行访问时,其内部过程大概是:

此前,在处理登录的业务中,当视为登录成功时,返回的字符串并不是JWT数据,则应该将此数据改为必要的JWT数据。

@Service
public class AdminServiceImpl implements IAdminService {

   // ===== 原有其它代码 =====

    /**
     * JWT数据的密钥
     */
    private String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";

    @Override
    public String login(AdminLoginDTO adminLoginDTO) {
        // ===== 原有其它代码 =====

        // 如果程序可以执行到此处,则表示登录成功
        // 生成此用户数据的JWT
        // Claims
        User user = (User) authenticate.getPrincipal();
        System.out.println("从认证结果中获取Principal=" + user.getClass().getName());
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", user.getUsername());
        claims.put("permissions", user.getAuthorities());
        System.out.println("即将向JWT中写入数据=" + claims);

        // JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
        String jwt = Jwts.builder()
                // Header:指定算法与当前数据类型
                // 格式为: { "alg": 算法, "typ": "jwt" }
                .setHeaderParam(Header.CONTENT_TYPE, "HS256")
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                // Payload:通常包含Claims(自定义数据)和过期时间
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
                // Signature:由算法和密钥(secret key)这2部分组成
                .signWith(SignatureAlgorithm.HS256, secretKey)
                // 打包生成
                .compact();

        // 返回JWT数据
        return jwt;
    }

}

在控制器中,应该响应JSON格式的数据,所以,需要在csmall-passport中添加依赖csmall-common

将控制器中处理请求的方法的返回值类型改为JsonResult<String>,并调整返回值:

// http://localhost:8080/admins/login?username=root&password=123456
@RequestMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
    String jwt = adminService.login(adminLoginDTO);
    return JsonResult.ok(jwt);
}

此时,重启项目,在浏览器中,使用正确的用户名和密码访问,响应的结果例如:

{
    "state":20000,
    "message":null,
    "data":"eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJwZXJtaXNzaW9ucyI6W3siYXV0aG9yaXR5IjoiL2Ftcy9hZG1pbi9kZWxldGUifSx7ImF1dGhvcml0eSI6Ii9hbXMvYWRtaW4vcmVhZCJ9LHsiYXV0aG9yaXR5IjoiL2Ftcy9hZG1pbi91cGRhdGUifSx7ImF1dGhvcml0eSI6Ii9wbXMvcHJvZHVjdC9kZWxldGUifSx7ImF1dGhvcml0eSI6Ii9wbXMvcHJvZHVjdC9yZWFkIn0seyJhdXRob3JpdHkiOiIvcG1zL3Byb2R1Y3QvdXBkYXRlIn1dLCJleHAiOjE2NTU0MzQwMzcsInVzZXJuYW1lIjoicm9vdCJ9.8ZIfpxxjJlwNo-E3JhXwH4sZR0J5-FU-HAOMu1Tg-44"
}

注意:以上只是访问/admins/login时会执行所编写的流程(发送用户名和密码,得到含JWT的结果),并不代表真正意义的实现了“登录”!

登录的流程应该是:客户端提交用户名和密码到服务器端 >>> 服务器端认证成功后响应JWT >>> 客户端在后续的请求中都携带JWT >>> 服务器端验证JWT来决定是否允许访问。

为了便于体现“客户端在后续的请求中都携带JWT”的操作,可以在项目中添加使用Knife4j。

当使用Knife4j时,需要在白名单中添加相关的放行资源路径,否则,Knife4j的页面将无法使用:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // ===== 原有其它代码 =====

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // ===== 原有其它代码 =====

        // URL白名单
        String[] urls = {
                "/admins/login",
                "/doc.html",  // 从本行开始,以下是新增
                "/**/*.js",
                "/**/*.css",
                "/swagger-resources",
                "/v2/api-docs",
                "/favicon.ico"
        };

        // ===== 原有其它代码 =====
    }
}

在后续的访问中,必须在请求中携带JWT数据, 服务器端才可以尝试解析此JWT数据,从而判断用户是否已登录或允许访问。

为了便于测试,在控制器中添加一个测试访问的请求配置:

// 以下是测试访问的请求
@GetMapping("/hello")
public String sayHello() {
    return "hello~~~";
}

由于以上 /admins/hello 路径并不在白名单中,如果直接访问,会出现403错误。

在规范的使用方式中,JWT数据必须携带在请求头(Request Header)的Authorization属性中。

按照以上规范,则服务器端在每次接收到请求后,首先,就应该判断请求头中是否存在AuthorizationAuthorization的值是否有效等操作,通常,是通过过滤器来实现以上检查的。

csmall-passport的根包下的security包下创建JwtAuthenticationFilter过滤器类,需要继承自OncePerRequestFilter类:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        System.out.println("JwtAuthenticationFilter.doFilterInternal()");
    }

}

所有的过滤器都必须注册后才可以使用,且同一个项目中允许存在多个过滤器,形成过滤器链,以上用于验证JWT的过滤器应该运行在Spring Security处理登录的过滤器之前,需要在自定义的SecurityConfiguration中的configure()方法中将以上自定义的过滤器注册在Spring Security的相关过滤器之前:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // 新增
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    // ===== 原有其它代码 =====

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // ===== 原有其它代码 =====

        // 注册处理JWT的过滤器
        // 此过滤器必须在Spring Security处理登录的过滤器之前
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

完成后,重启项目,无论对哪个路径发出请求,在控制台都可以看出输出了过滤器中的输出语句内容,并且,在浏览器将显示一片空白。

关于JwtAuthenticationFilter,它需要实现:

package cn.tedu.csmall.passport.security;

import cn.tedu.csmall.common.web.JsonResult;
import cn.tedu.csmall.common.web.State;
import com.alibaba.fastjson.JSON;
import io.jsonwebtoken.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;

/**
 * JWT过滤器:从请求头的Authorization中获取JWT中存入的用户信息
 * 并添加到Spring Security的上下文中
 * 以致于Spring Security后续的组件(包括过滤器等)能从上下文中获取此用户的信息
 * 从而验证是否已经登录、是否具有权限等
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    /**
     * JWT数据的密钥
     */
    private String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        System.out.println("JwtAuthenticationFilter.doFilterInternal()");
        // 清除Spring Security上下文中的数据
        // 避免此前曾经存入过用户信息,后续即使没有携带JWT,在Spring Security仍保存有上下文数据(包括用户信息)
        System.out.println("清除Spring Security上下文中的数据");
        SecurityContextHolder.clearContext();
        // 客户端提交请求时,必须在请求头的Authorization中添加JWT数据,这是当前服务器程序的规定,客户端必须遵守
        // 尝试获取JWT数据
        String jwt = request.getHeader("Authorization");
        System.out.println("从请求头中获取到的JWT=" + jwt);
        // 判断是否不存在jwt数据
        if (!StringUtils.hasText(jwt)) {
            // 不存在jwt数据,则放行,后续还有其它过滤器及相关组件进行其它的处理,例如未登录则要求登录等
            // 此处不宜直接阻止运行,因为“登录”、“注册”等请求本应该没有jwt数据
            System.out.println("请求头中无JWT数据,当前过滤器将放行");
            filterChain.doFilter(request, response); // 继续执行过滤器链中后续的过滤器
            return; // 必须
        }

        // 注意:此时执行时,如果请求头中携带了Authentication,日志中将输出,且不会有任何响应,因为当前过滤器尚未放行
        // 以下代码有可能抛出异常的
        // TODO 密钥和各个Key应该统一定义
        String username = null;
        String permissionsString = null;
        try {
            System.out.println("请求头中包含JWT,准备解析此数据……");
            Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
            username = claims.get("username").toString();
            permissionsString = claims.get("permissions").toString();
            System.out.println("username=" + username);
            System.out.println("permissionsString=" + permissionsString);
        } catch (ExpiredJwtException e) {
            System.out.println("解析JWT失败,此JWT已过期:" + e.getMessage());
            JsonResult<Void> jsonResult = JsonResult.fail(
                    State.ERR_JWT_EXPIRED, "您的登录已过期,请重新登录!");
            String jsonString = JSON.toJSONString(jsonResult);
            System.out.println("响应结果:" + jsonString);
            response.setContentType("application/json; charset=utf-8");
            response.getWriter().println(jsonString);
            return;
        } catch (MalformedJwtException e) {
            System.out.println("解析JWT失败,此JWT数据错误,无法解析:" + e.getMessage());
            JsonResult<Void> jsonResult = JsonResult.fail(
                    State.ERR_JWT_MALFORMED, "获取登录信息失败,请重新登录!");
            String jsonString = JSON.toJSONString(jsonResult);
            System.out.println("响应结果:" + jsonString);
            response.setContentType("application/json; charset=utf-8");
            response.getWriter().println(jsonString);
            return;
        } catch (SignatureException e) {
            System.out.println("解析JWT失败,此JWT签名错误:" + e.getMessage());
            JsonResult<Void> jsonResult = JsonResult.fail(
                    State.ERR_JWT_SIGNATURE, "获取登录信息失败,请重新登录!");
            String jsonString = JSON.toJSONString(jsonResult);
            System.out.println("响应结果:" + jsonString);
            response.setContentType("application/json; charset=utf-8");
            response.getWriter().println(jsonString);
            return;
        } catch (Throwable e) {
            System.out.println("解析JWT失败,异常类型:" + e.getClass().getName());
            e.printStackTrace();
            JsonResult<Void> jsonResult = JsonResult.fail(
                    State.ERR_INTERNAL_SERVER_ERROR, "获取登录信息失败,请重新登录!");
            String jsonString = JSON.toJSONString(jsonResult);
            System.out.println("响应结果:" + jsonString);
            response.setContentType("application/json; charset=utf-8");
            response.getWriter().println(jsonString);
            return;
        }

        // 将此前从JWT中读取到的permissionsString(JSON字符串)转换成Collection<? extends GrantedAuthority>
        List<SimpleGrantedAuthority> permissions
                = JSON.parseArray(permissionsString, SimpleGrantedAuthority.class);
        System.out.println("从JWT中获取到的权限转换成Spring Security要求的类型:" + permissions);
        // 将解析得到的用户信息传递给Spring Security
        // 获取Spring Security的上下文,并将Authentication放到上下文中
        // 在Authentication中封装:用户名、null(密码)、权限列表
        // 因为接下来并不会处理认证,所以Authentication中不需要密码
        // 后续,Spring Security发现上下文中有Authentication时,就会视为已登录,甚至可以获取相关信息
        Authentication authentication
                = new UsernamePasswordAuthenticationToken(username, null, permissions);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        System.out.println("将解析得到的用户信息传递给Spring Security");
        // 放行
        System.out.println("JwtAuthenticationFilter 放行");
        filterChain.doFilter(request, response);
    }

}

要使用Spring Security实现授权访问,首先,必须保证用户登录后,在Spring Security上下文中存在权限相关信息(目前,此项已完成,在JwtAuthenticationFilter的最后,已经存入权限信息)。

然后,需要在配置类上使用@EnableGlobalMethodSecurity注解开启“通过注解配置权限”的功能,所以,在SecrutiyConfiguration类上添加:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    // ===== 类中原有代码 =====
}

最后,在任何你需要设置权限的处理请求的方法上,通过@PreAuthorize注解来配置要求某种权限,例如:

@GetMapping("/hello")
@PreAuthorize("hasAuthority('/ams/admin/read')") // 新增
public String sayHello() {
    return "hello~~~";
}

完成后,重启项目,使用具有/ams/admin/read权限的用户可以直接访问,不具有此权限的用户则不能访问(将出现403)。

基于JWT实现SSO单点登录

single sign on(SSO) 的效果是什么?

  1. 用户在应用a触发了登录,那么a会拿到一个jwt信息
  2. 用户在应用b不用登录,会发现已经登录过了?然后返回一个jwt给应用b。完成应用b的登录

这里没有搞明白是怎么控制的、后面再来完善

创建项目结构

不在之前的项目上继续了,之前的项目用于讲解浏览器和app不同的支持;

这次的是既支持浏览器跳转也支持json返回这种。

编写的代码比较简单。从零开始搭建sso需要做些什么。更容易理解;

如果自己有任何需求,可以自定义的去实现合并某些功能

项目结构:分别对应上图的4个角色;

sso-client1
sso-client2
sso-demo
sso-server

4个都是gradle的模块

rootProject.name = 'spring-security'
include 'security-app'
include 'security-browser'
include 'security-core'
include 'security-demo'
include 'sso-client1'
include 'sso-client2'
include 'sso-demo'
include 'sso-server'

认证服务器的搭建

sso-server/build.gradle

dependencies {
	compile('org.springframework.boot:spring-boot-starter-security')
	compile('org.springframework.boot:spring-boot-starter-web')
	compile 'org.springframework.security.oauth:spring-security-oauth2'
	compile 'org.springframework.security:spring-security-jwt'
	testCompile('org.springframework.boot:spring-boot-starter-test')
	testCompile('org.springframework.security:spring-security-test')
}

对比下面的配置:

这里的配置与之前的core中的配置最大的区别是,没有引入 org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.0.0.RELEASE
可以是因为这个原因,才不需要设置authenticationManager吧?


package cn.mrcode.imooc.springsecurity.sso.ssoserver;

@Configuration
@EnableAuthorizationServer
public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("myid1")
                .secret("myid1")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("all")
                .redirectUris(
                        "http://example.com",
                        "http://ora.com")
                .and()
                .withClient("myid2")
                .secret("myid2")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("all");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 客户端来向认证服务器获取签名的时候需要登录认证身份才能获取
        // 因为客户端需要用密钥解密jwt字符串
        security.tokenKeyAccess("isAuthenticated()");
    }

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("imooc");
        return converter;
    }
}

注意改成默认的basic;

package cn.mrcode.imooc.springsecurity.sso.ssoserver;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic();
    }
}

sso-server application.yml

server:
  port: 9999
  servlet:
    context-path: /server

spring:
  security:
    user:
      password: 123456

启动项目,一切正常;

浏览器访问以下地址获取code;发现连授权页面都和以前的不一样了。我有点费解

http://localhost:9999/server/oauth/authorize?response_type=code&client_id=myid1&redirect_uri=http://www.example.com&scope=all

client1 和 client2

添加依赖

dependencies {
	compile('org.springframework.boot:spring-boot-starter-security')
	// @EnableOAuth2Sso 是该包的注解
	compile 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure'

	compile('org.springframework.boot:spring-boot-starter-web')
	compile 'org.springframework.security.oauth:spring-security-oauth2'
	compile 'org.springframework.security:spring-security-jwt'
	testCompile('org.springframework.boot:spring-boot-starter-test')
	testCompile('org.springframework.security:spring-security-test')
}

package cn.mrcode.imooc.springsecurity.sso.ssoclient1;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
@EnableOAuth2Sso // 开启单点登录
public class SsoClient1Application {

    public static void main(String[] args) {
        SpringApplication.run(SsoClient1Application.class, args);
    }

    //编写一个获取当前服务器的用户信息控制器
    @GetMapping("/user")
    public Authentication user(Authentication user){
        return user;
    }
}

application.yml
client1 和 client2 不同的配置就是 client 信息,还有端口号,context-path(其实这里context-path是可以不用配置的)

security:
  oauth2:
    client:
      clientId: myid1
      clientSecret: myid1
      user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize
      access-token-uri: http://127.0.0.1:9999/server/oauth/token
    resource:
      jwt:
        key-uri: http://127.0.0.1:9999/server/oauth/token_key
      user-info-uri: http://127.0.0.1:9999/server/user
      token-info-uri: http://127.0.0.1:9999/server/oauth/check_token
      preferTokenInfo: false

server:
  port: 8080
  servlet:
    context-path: /client1

上面的属性特别是:user-info-uri 和 token-info-uri 不配置就会报错;可以参考官网文档

// 具体的配置属性在该类中有检查
org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties

以上配置产考官网文档,因为spring boot2 和 1.5 不一样了
https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/html5/#boot-features-security-oauth2-single-sign-on

配置一个 首页 static/index.html;

client1 和 client2 唯一不同的就是,我们要实现,在client1上跳转client2;
client2上跳转到clinet1上;所以首页中的跳转地址不一样而已

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSO Client1</title>
</head>
<body>
	<h1>SSO Demo Client1</h1>
	<a href="http://localhost:8060/client2/index.html">访问Client2</a>
</body>
</html>

测试

启动server,client1 ;
在启动client1的时候会去server拿jwtkey;

  1. 访问 http://localhost:8080/client1

  2. 这个时候会跳转到 端口9999的认证服务器,

    在天厨的basic登录框中填入user,123456 (由于认证服务器没有配置自定义用户信息,默认用户)
    如果报以下错误,去把认证服务器中的client信息授权跳转域增加一个路径 http://localhost:8080/client1/login

    OAuth Error
    error="invalid_grant", error_description="Invalid redirect: http://localhost:8080/client1/login does not match one of the registered values: [http://example.com, http://ora.com]"
    

    server配置更改

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("myid1")
                .secret("myid1")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("all")
                .redirectUris(
                        "http://localhost:8080/client1/login")
                .and()
                .withClient("myid2")
                .secret("myid2")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("all")
                .redirectUris(
                        "http://localhost:8060/client2/login");
    }
    还有一个地方,由于security5+必须要配置密码策略,否则在登录提交后会在后台报错,找不到策略id
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.passwordEncoder(NoOpPasswordEncoder.getInstance()); // 密码加密策略
        security.tokenKeyAccess("isAuthenticated()");
    }
    
  3. 在认证服务器登录完成后,会默认跳转回http://localhost:8080/client1/login

    框架带着code完成剩下的步骤,最终默认跳转到首页。之前讲原理的时候说过的
    为什么是login?之前原理中有讲述,客户端会拦截到你需要登录才能继续访问,于是跳转到了login
    login发现自己是一个sso资源服务器,就跳转到了认证服务器

  4. 访问地址 http://localhost:8080/client1/user 打印出当前登录的用户信息

这个时候就可以把 启动server,client1 ,client2 全部启动,
测试登录后 点击首页的跳转连接,查看互相跳转不需要再次登录的效果;
并对比访问各自的user信息查看tokenValue是否一致;

完成了两个系统的单点登录效果;
目前有我一个问题没有想明白:在clinet1登录后,当访问client2的时候也没有带什么东西为什么就中的myid2是和user关联的?

通过对获取授权码的端点/oauth/authorize源码的跟中。
org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint#authorize

发现进入该方法的时候就已经有Principal信息了并且是user的信息;
至于这个信息是怎么来的,有必要再去跟下security的最前面看这个信息是怎么被认定的;暂时不跟了,太耗时间了

然后这里体验之后会发现两个问题:

  1. 认证服务器的登录页面 是basci

期望效果:使用自定义的登录页面
2. 每次都需要授权

期望效果:第一次登录的时候授权,后面跳转到其他应用不需要手动点击授权了
3. 不是自定义的用户

自定义登录页面和用户自定义

由于登录页面是在认证服务器上,所以修改认证服务器配置;其他的个性化配置,自己以后根据业务去细化

package cn.mrcode.imooc.springsecurity.sso.ssoserver;

@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
//                .httpBasic()
                .formLogin()  // 更改为form表单登录
                .and()
                // 所有的请求都必须授权后才能访问
                .authorizeRequests()
                .anyRequest()
                .authenticated();
        ;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}

自定义用户信息;无非就是之前讲过的自定义 userDetailsService

package cn.mrcode.imooc.springsecurity.sso.ssoserver;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * @author : zhuqiang
 * @version : V1.0
 * @date : 2018/8/12 14:16
 */
@Component
public class SsoUserDetailsService implements UserDetailsService {
    Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String password = passwordEncoder.encode("123456");
        logger.info("用户名 {},数据库密码{}", username, password);
        User admin = new User(username,
//                              "{noop}123456",
                password,
                true, true, true, true,
                AuthorityUtils.commaSeparatedStringToAuthorityList(""));
        return admin;
    }
}

自动授权

自动授权的思路:

  1. 跟踪源码,找到自动授权页面的产出处
  2. 想办法跳过授权,或自动授权
通过全局搜索授权页面的标题文字 OAuth Approval
定位到如下的类  
org.springframework.security.oauth2.provider.endpoint.WhitelabelApprovalEndpoint

再看谁调用了上面这个类,找到类配置初始化对象的源码
org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration#whitelabelApprovalEndpoint

@Bean
public WhitelabelApprovalEndpoint whitelabelApprovalEndpoint() {
  return new WhitelabelApprovalEndpoint();
}


根据上面的源码来看,没有提供配置可替换的配置,那么视频中说过@FrameworkEndpoint注解
的优先级没有我们自定义的优先级高,我们定义同样的配置控制器路径即可;

这里和视频中的源码不太一样了。 在新版中,创建授权页html的方法是一个 protected String createTemplate ;

意味着我们可以继承该类,然后重写模板即可;

思路永远会被现实打脸;在实现过程中发现不能直接继承,下面代码中的注释部分是新加的,其他的代码全部拷贝过来了

package cn.mrcode.imooc.springsecurity.sso.ssoserver;

import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.endpoint.WhitelabelApprovalEndpoint;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.HtmlUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

/**
 * 授权确认服务:不能继承 WhitelabelApprovalEndpoint,因为FrameworkEndpoint会被扫描,就会存在两个一样的地址;报错
 * @author : zhuqiang
 * @version : V1.0
 * @date : 2018/8/12 14:32
 * @see WhitelabelApprovalEndpoint
 */
@RestController
@SessionAttributes("authorizationRequest")
public class MyWhitelabelApprovalEndpoint {
    @RequestMapping("/oauth/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        final String approvalContent = createTemplate(model, request);
        if (request.getAttribute("_csrf") != null) {
            model.put("_csrf", request.getAttribute("_csrf"));
        }
        View approvalView = new View() {
            @Override
            public String getContentType() {
                return "text/html";
            }

            @Override
            public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
                response.setContentType(getContentType());
                response.getWriter().append(approvalContent);
            }
        };
        return new ModelAndView(approvalView, model);
    }

    protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
        String clientId = authorizationRequest.getClientId();

        StringBuilder builder = new StringBuilder();
        // 让body不显示
        builder.append("<html><body style='display:none;'><h1>OAuth Approval</h1>");
        builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
        builder.append("\" to access your protected resources?</p>");
        builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");

        String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
        if (requestPath == null) {
            requestPath = "";
        }

        builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
        builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");

        String csrfTemplate = null;
        CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
        if (csrfToken != null) {
            csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
                    "\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
        }
        if (csrfTemplate != null) {
            builder.append(csrfTemplate);
        }

        String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";

        if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
            builder.append(createScopes(model, request));
            builder.append(authorizeInputTemplate);
        } else {
            builder.append(authorizeInputTemplate);
            builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
            builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
            builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
            if (csrfTemplate != null) {
                builder.append(csrfTemplate);
            }
            builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
        }

        // 添加自动提交操作
        builder.append("<script>document.getElementById('confirmationForm').submit()</script>");
        builder.append("</body></html>");

        return builder.toString();
    }

    private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
        StringBuilder builder = new StringBuilder("<ul>");
        @SuppressWarnings("unchecked")
        Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
                model.get("scopes") : request.getAttribute("scopes"));
        for (String scope : scopes.keySet()) {
            String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
            String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
            scope = HtmlUtils.htmlEscape(scope);

            builder.append("<li><div class=\"form-group\">");
            builder.append(scope).append(": <input type=\"radio\" name=\"");
            builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
            builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
            builder.append(denied).append(">Deny</input></div></li>");
        }
        builder.append("</ul>");
        return builder.toString();
    }
}