springcloud2.2.1 oauth2实现用户认证授权及sso

  • Post author:
  • Post category:其他

项目源码      

     基于cas实现的sso虽然实现比较简单,但功能实在是单一,性能(每个请求都有验证ticket),可靠性(必须保证认证服务器高可用)都得打个问号,而oauth2功能强大,整合spring security,jwt,能实现一套用户session管理,token刷新,权限控制,跨一级域名单点登录等,是一套强大的组合拳,oauth2也是第三方微信,QQ授权登录的核心。

一、基本原理-参考

同域单点登录原理

  1. 门户系统设置 Cookie 的 domain 为一级域名也就是 zlt.com,这样就可以共享门户的 Cookie 给所有的使用该域名(xxx.zlt.com)的系统
  2. 使用 Spring Session 等技术让所有系统共享 Session
  3. 这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户 Cookie 中的 sessionId 读取到 Session 中的登录信息实现单点登录

Oauth2跨域单点登录

  1. 访问系统1判断未登录,则跳转到UAA系统请求授权
  2. UAA系统域名 sso.com 下的登录地址中输入用户名/密码完成登录
  3. 登录成功后UAA系统把登录信息保存到 Session 中,并在浏览器写入域为 sso.com 的 Cookie
  4. 访问系统2判断未登录,则跳转到UAA系统请求授权
  5. 由于是跳转到UAA系统的域名 sso.com 下,所以能通过浏览器中UAA的 Cookie 读取到 Session 中之前的登录信息完成单点登录

二、认证服务器雏形

1.依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/> 
    </parent>
    <groupId>com.jwolf</groupId>
    <artifactId>oauth2sso-server</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>oauth2sso-server</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.SR6</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
      
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

2.spring security相关配置

@Service
public class UserService implements UserDetailsService {

    private List<User> userList=new ArrayList<>(8);

    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 这里为了方便这里就不查数据库了
     */
    @PostConstruct
    public void initData() {
        userList.add(new User("user1",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("super")));
        userList.add(new User("user2",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("admin")));
        userList.add(new User("user3",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("common")));
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> optionalUser = userList.stream().filter(item -> item.getUsername().equals(username)).findAny();
        if (optionalUser==null) {
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        //这里把查到的用户返回就可以了,spring security内部会进行密码比对
        return optionalUser.get();
    }
}

神坑预警:这里的用户列表要么从DB查,要么写的方法内每次重新创建,否则会出现调用localhost:9402/logout或清除cookie后再次登录报警告“encoded password  empty。。”导致无法再次登录,重启认证服务器才能登录

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().and().authorizeRequests().anyRequest().authenticated();
    }

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

3.oauth2相关配置

@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("client1")
                .secret(passwordEncoder().encode("secret1"))
                .authorizedGrantTypes("authorization_code")
                .scopes("all")
                //.accessTokenValiditySeconds(10)
                //.refreshTokenValiditySeconds(864000)
                .redirectUris("http://baidu.com","http://localhost:9502/login") // 授权成功后运行跳转的url,sso客户端默认/login,可在client端通过security.oauth2.sso.login-path修改为其它
                .autoApprove(false)  // true则自动授权,跳过授权页面点击步骤
                .and()
                .withClient("client2")
                .secret(passwordEncoder().encode("secret2"))
                .authorizedGrantTypes("authorization_code")
                .scopes("all")
                //.accessTokenValiditySeconds(10)
                //.refreshTokenValiditySeconds(864000)
                .redirectUris("http://localhost:9602/login")
                .autoApprove(false);

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

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 要访问认证服务器tokenKey的时候需要经过身份认证
        security.tokenKeyAccess("isAuthenticated()");
    }

    /**
     * 可以使用jdbc,redis,jwt,memory等方式存储token
     * @return
     */
    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        // JWT签名用的key,泄漏会导致JWT被伪造
        converter.setSigningKey("dev");
        return converter;
    }

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

}

4.application.yml及启动类

server:
  port: 9402
spring:
  application:
    name: oauth2-server
/**
 * <p>
 * Description: 认证服务器
 * </p>
 *
 * @author majun
 * @version 1.0
 * @date 2020-10-28 20:59
 */
@SpringBootApplication
@EnableAuthorizationServer
@EnableWebSecurity
public class Oauth2ssoApplication {
    public static void main(String[] args) {
        SpringApplication.run(Oauth2ssoApplication.class, args);
    }
}

三、SSO client端(ssclient1,client2)

1.依赖同认证服务器,可增加一个解析JWT的工具包

	<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
			<version>0.9.1</version>
		</dependency>

2.测试接口 ,注:用户信息,jwt,权限可以从SecurityContextHolder.getContext()获取

@RestController
@RequestMapping("/user")
public class UserController {
    /**
     * 测试接口-获取用户信息
     * @param authentication
     * @return
     */
    @GetMapping("/getCurrentUser")
    //@PreAuthorize("hasAuthority('super')")
    //@PreAuthorize("hasAnyRole('ROLE_NORMAL')")
    //@PreAuthorize("hasPermission('')")
    public Object getCurrentUser(Authentication authentication) {
       //解析看看JWT内容
        OAuth2AuthenticationDetails details =(OAuth2AuthenticationDetails) authentication.getDetails();
        Claims claims = Jwts.parser()
                .setSigningKey("dev".getBytes(StandardCharsets.UTF_8))
                .parseClaimsJws(details.getTokenValue())
                .getBody();

        System.out.println(claims);
        return authentication;
    }
}

3. client1,client2的application.yml ,启动类 @EnableOAuth2Sso需要开启SSO

server:
  port: 9502
  servlet:
    session:
      cookie:
        # 防止cookie冲突,冲突会导致登录验证不通过,如果不同client端使用相同的cookie名会导致挤掉另一client授权的cookie被覆盖
        name: OAUTH2-CLIENT1-SESSIONID


spring:
  application:
    name: oauth2-client1

oauth2-service-url: http://localhost:9402

security:
  oauth2:
    client:
      client-id: client1 
      client-secret: secret1
      user-authorization-uri: ${oauth2-service-url}/oauth/authorize 
      access-token-uri: ${oauth2-service-url}/oauth/token
    resource:
      jwt:
        key-uri: ${oauth2-service-url}/oauth/token_key
        key-value: dev #对应认证服务器jwt key的签名
server:
  port: 9502
  servlet:
    session:
      cookie:
        # 防止cookie冲突,冲突会导致登录验证不通过,如果不同client端使用相同的cookie名会导致挤掉另一client授权的cookie被覆盖
        name: OAUTH2-CLIENT1-SESSIONID


spring:
  application:
    name: oauth2-client2

oauth2-service-url: http://localhost:9402

security:
  oauth2:
    client:
      client-id: client1 
      client-secret: secret1
      user-authorization-uri: ${oauth2-service-url}/oauth/authorize 
      access-token-uri: ${oauth2-service-url}/oauth/token
    resource:
      jwt:
        key-uri: ${oauth2-service-url}/oauth/token_key
        key-value: dev #对应认证服务器jwt key的签名

四、测试

访问client2业务接口:http://localhost:9602/user/getCurrentUser

因为没登录重定向到认证服务器去登录,user1/123456

点击授权后跳回client2的业务接口

然后访问client1业务接口:http://localhost:9502/user/getCurrentUser,因为client2已登录,client1就不用再登录了,直接跳到授权页面,点击同意授权直接跳回业务接口

五、系统优化及进阶使用

1.页面美化—认证服务器新增自定义登录页面mylogin.html,自定义认证页面mygrant.html及一个控制视图跳转的CustomAuthController,然后SecurityConfig修改相关配置即可。走起>>>

新增thymeleaf依赖及配置

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
spring:
  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html
    cache: false

自定义登录页与授权页面

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>

<body>
<div class="login-container">
    <div class="form-container">
        <p class="title">用户登录</p>
        <form name="loginForm" method="post"  action="/authentication/form" >
            <input type="text" name="username" placeholder="用户名"/>
            <br>
            <input type="password" name="password" placeholder="密码"/>
            <br>
            <button type="submit" class="btn">登 &nbsp;&nbsp; 录</button>
        </form>
        <p style="color: red" th:if="${param.error}">用户名或密码错误</p>
    </div>
</div>
</body>
<style>
    .login-container {
        margin: 50px;
        width: 100%;
    }

    .form-container {
        margin: 0px auto;
        width: 50%;
        text-align: center;
        box-shadow: 1px 1px 10px #888888;
        height: 300px;
        padding: 5px;
    }

    input {
        margin-top: 10px;
        width: 350px;
        height: 30px;
        border-radius: 3px;
        border: 1px #E9686B solid;
        padding-left: 2px;

    }


    .btn {
        width: 350px;
        height: 35px;
        line-height: 35px;
        cursor: pointer;
        margin-top: 20px;
        border-radius: 3px;
        background-color: #E9686B;
        color: white;
        border: none;
        font-size: 15px;
    }

    .title{
        margin-top: 5px;
        font-size: 18px;
        color: #E9686B;
    }
</style>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>授权</title>
</head>

<body style="margin: 0px">

<div class="container">
    <h3 th:text="${clientId}+' 请求授权,该应用将获取你的以下信息'"></h3>
    <p>昵称,头像和性别</p>
    授权后表明你已同意 <a  href="#boot" style="color: #E9686B" th:text="${clientId}+'服务协议'"></a>
    <form method="post" action="/oauth/authorize">

        <input type="hidden" name="user_oauth_approval" value="true">

        <div th:each="item:${scopes}">
            <input type="radio" th:name="'scope.'+${item}" value="true" hidden="hidden" checked="checked"/>
        </div>

        <button class="btn" type="submit"> 同意/授权</button>

    </form>
</div>
</body>

<style>

    html{
        padding: 0px;
        margin: 0px;
    }
    .title-left a{
        color: white;
    }
    .container{
        clear: both;
        text-align: center;
    }
    .btn {
        width: 350px;
        height: 35px;
        line-height: 35px;
        cursor: pointer;
        margin-top: 20px;
        border-radius: 3px;
        background-color: #E9686B;
        color: white;
        border: none;
        font-size: 15px;
    }
</style>
</html>

页面跳转的控制

@Controller
@SessionAttributes("authorizationRequest")
public class CustomAuthController {
    /**
     * 跳到自定义登录页面,需要在security配置该path
     * @return
     */
   @RequestMapping("/mylogin")
   public String getMyLogin(){
       return "mylogin";
   }

    /**
     * 跳到自定义授权页面(oauth2默认使用该path)
     * @param model
     * @param request
     * @return
     * @throws Exception
     */

    @RequestMapping("/oauth/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
        ModelAndView mv = new ModelAndView();
        mv.setViewName("mygrant");
        mv.addObject("clientId", authorizationRequest.getClientId());
        mv.addObject("scopes",authorizationRequest.getScope());
        return mv;

    }
}

SecurityConfig配置修改

package com.jwolf.oauth2ssoserver.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
       // http.formLogin().and().authorizeRequests().anyRequest().authenticated()
        http.formLogin()
                .loginPage("/mylogin") //登录页view,默认/login
                .loginProcessingUrl("/authentication/form")//与登录页form提交的url一致,
                .and()
                .authorizeRequests().antMatchers("/user/xxx", "/mylogin").permitAll()
                .anyRequest().authenticated()
                .and()
                .logout()
                .logoutUrl("/logout") //默认logout
                //.logoutSuccessUrl("/xxxx")
                .deleteCookies("OAUTH2-CLIENT2-SESSIONID","OAUTH2-CLIENT1-SESSIONID")
                .and()
                .csrf().disable();
    }

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

美化效果

2.jwt增强——默认只有client_id,user_name,exp,权限等字段,可以通过jwt增强

 @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> delegates = new ArrayList<>();
        delegates.add((oAuth2AccessToken, oAuth2Authentication) -> {
            Map<String, Object> info = new HashMap<>();
            info.put("info1", "111111");
            info.put("info2", "222222");
            ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
            return oAuth2AccessToken;
        });
        //注意jwtAccessTokenConverter一定要在其它增强链最后,它的作用是生成jwt字符串
        delegates.add(jwtAccessTokenConverter());
        tokenEnhancerChain.setTokenEnhancers(delegates);
        endpoints.tokenStore(jwtTokenStore())
                .userDetailsService(userDetailsService)
                .tokenEnhancer(tokenEnhancerChain);
    }

3.SSO client端权限

启动类开启权限开关(多种),默认关闭

@EnableGlobalMethodSecurity(prePostEnabled = true)

业务接口新增权限注解

    @PreAuthorize("hasAuthority('super')")
    //@PreAuthorize("hasAnyRole('ROLE_NORMAL')")
    //@PreAuthorize("hasPermission('')")。。。。。

只有有对应authority的user才能访问

4.微信,QQ,github等第三方认证登录的秘密

较大的几个社交平台都提供了oauth2授权登录(用的最严格也是最常用的授权码授权,如果是第二方可考虑简单一点的授权方式),其实是它们提供了一个较大的授权服务器,成为它们的开发者后申请到的appid,appsecret同自己的授权服务器Oauth2配置(如图AuthorizitionServerConfig里配置的client和secret),授权回调url等同于oauth2配置的redirectUrl,但它们的配置一定inMemory,而是jdbc存储,不用重启实时生效的,第三方授权登录本质也是sso,一种跨域的sso. 集成第三方授权配置参考:

security:
  oauth2:
    client:
      client-id: VXXXXXXXX
      client-secret: FgSvYXXdDFgSvYXXdD
      access-token-uri: http://openapi.baidu.com/oauth/2.0/token
      user-authorization-uri: http://openapi.baidu.com/oauth/2.0/authorize
    resource:
      userInfoUri: https://openapi.baidu.com/rest/2.0/passport/users/getInfo

5.oauth2繁杂的授权码认证流程框架内部直接走完了,现在来看细节。其它授权方式及刷新令牌参考,简要步骤

  • 用户先在授权服务器进行认证
  • 认证成功后用户再进行授权
  • 授权之后授权服务器返回给客户端一个授权码
  • 客户端再使用获取到的授权码到授权服务器换取访问令牌
  • 客户端使用访问令牌到资源服务器获取用户资源

5.1 oauth2配置client1新增一个允许的redirectUrl,如https://jd.com

5.2.获取授权码

直接访问浏览器访问:http://localhost:9402/oauth/authorize?client_id=client1&redirect_uri=http://baidu.com&response_type=code

这里有个坑:

这里测试一般使用http://baidu.com测试,使用http://jd.com测试回调后授权码code没有,注意单点登录登录后授权页面这个链接http://localhost:9402/oauth/authorize?client_id=client1&redirect_uri=http://localhost:9502/user/mylogin&response_type=code&scope=all&state=F0ECjt(FOECjt不是授权码)

登录并同意授权后从响应的链接百度一下,你就知道 获取的授权码

5.3.postman发送POST请求,注意相关参数与授权链接的一直,并且只能访问一次

使用access_token访问资源服务器资源,可以使用postman Auth功能,其实就是通过1,2,3步骤添加了一个Authorization请求头,也可以直接http://localhost:9502/user/getCurrentUser?access_token=上面的access_token. 注意@EnableOAuth2Sso不是@EnableResourceServer复合注解,需要通过access_token手动访问资源服务器接口必须启动类加上@EnableResourceServer但是加上后所有的sso客户端所有资源都会被保护,不能直接访问了,需要另外的处理。。。(资源服务器与sso客户端区别??)

刷新令牌:http://localhost:9402/oauth/token?grant_type=refresh_token&client_id=client1&client_secret=secret1&scope=all&refresh_token=上面的freshToken,可获取新的access_token及新的refresh_token。 

        刷新令牌(refresh_token)也可以用来获取访问令牌(access_token),使用刷新令牌获取访问令牌的过程并不是一种独立的授权方式。刷新令牌只是授权之后随着访问令牌一起返回来的结果而已,而且只有授权码授权方式和密码授权方式才会有刷新令牌,而简化授权方式和客户端授权方式都没有刷新令牌。访问令牌一般都有一个较短的有效期,在有效期内,用户可以使用访问令牌到资源服务器获取用户资源,但是过了有效期,就需要用户重新进行认证并授权,为了减少用户重复认证并授权的复杂过程,就引入了刷新令牌。刷新令牌一般也有一个有效期,但比访问令牌的有效期要长一些,在刷新令牌的有效期之内,可以使用刷新令牌到授权服务器重新获取访问令牌(同时重新获取刷新令牌),但如果刷新令牌过期了,就无法使用刷新令牌来重新获取访问令牌了,此时必须通过认证并授权的方式来获取。

TODO: access_token濒临过期,如何利用refresh_token获取新的access_token让用户无感知????


版权声明:本文为qq_39506978原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。