Apollo 源碼解析 —— Portal 認證與授權(一)之認證

點擊上方“芋道源碼”,選擇“設爲星標

管她前浪,還是後浪?

能浪的浪,纔是好浪!

每天 8:55 更新文章,每天掉億點點頭髮...

源碼精品專欄

 

摘要: 原創出處 http://www.iocoder.cn/Apollo/portal-auth-1/ 「芋道源碼」歡迎轉載,保留摘要,謝謝!

  • 1. 概述

  • 2. AuthConfiguration

  • 3. Users

  • 4. Authorities

  • 5. UserService

  • 6. UserInfoHolder

  • 7. SsoHeartbeatHandler

  • 8. LogoutHandler


1. 概述

老艿艿:本系列假定胖友已經閱讀過 《Apollo 官方 wiki 文檔》 ,特別是 《Portal 實現用戶登錄功能》 。

本文分享 Portal 的認證與授權,側重在認證部分

在 《Portal 實現用戶登錄功能》 文檔的開頭:

Apollo 是配置管理系統,會提供權限管理(Authorization),理論上是不負責用戶登錄認證功能的實現(Authentication)。

所以 Apollo 定義了一些SPI用來解耦,Apollo 接入登錄的關鍵就是實現這些 SPI 。

和我們理解的 JDK SPI 不同,Apollo 是基於 Spring Profile 的特性,配合上 Spring Java Configuration 實現了類似 SPI 的功能。對於大多數人,我們可能比較熟悉的是,基於不同的 Profile 加載不同環境yamlproperties 配置文件。所以,當筆者看到這樣的玩法,也是眼前一亮。

apollo-portal 項目中,spi 包下,我們可以看到認證相關的配置實現,如下圖所示:

  • 綠框:接口。

  • 紫框:實現。

  • 紅框:配置接口對應的實現。

2. AuthConfiguration

com.ctrip.framework.apollo.portal.spi.configuration.AuthConfiguration認證 Spring Java 配置。如下圖:

目前有三種實現:

  • 第一種, profile=ctrip ,攜程內部實現,接入了SSO並實現用戶搜索、查詢接口。

  • 第二種,profile=auth ,使用 Apollo 提供的 Spring Security 簡單認證。

  • 第三種,profile 爲空,使用默認實現,全局只有 apollo 一個賬號。

一般情況下,我們使用第二種,基於 Spring Security 的實現。所以本文僅分享這種方式。對其他方式感興趣的胖友,可以自己讀下代碼哈。

整體類圖如下:

2.1 SpringSecurityAuthAutoConfiguration

UserService ,配置如下:

@Bean
@ConditionalOnMissingBean(UserService.class)
public UserService springSecurityUserService() {
    return new SpringSecurityUserService();
}
  • 使用 SpringSecurityUserService 實現類,在 「5. UserService」 中,詳細解析。


UserInfoHolder ,配置如下:

@Bean
@ConditionalOnMissingBean(UserInfoHolder.class)
public UserInfoHolder springSecurityUserInfoHolder() {
    return new SpringSecurityUserInfoHolder();
}
  • 使用 SpringSecurityUserInfoHolder 實現類,在 「6. UserInfoHolder」 中,詳細解析。


JdbcUserDetailsManager ,配置如下:

@Bean
public JdbcUserDetailsManager jdbcUserDetailsManager(AuthenticationManagerBuilder auth, DataSource datasource) throws Exception {
    JdbcUserDetailsManager jdbcUserDetailsManager = auth.jdbcAuthentication() // 基於 JDBC
            .passwordEncoder(new BCryptPasswordEncoder()) // 加密方式爲 BCryptPasswordEncoder
            .dataSource(datasource) // 數據源
            .usersByUsernameQuery("select Username,Password,Enabled from `Users` where Username = ?") // 使用 Username 查詢 User
            .authoritiesByUsernameQuery("select Username,Authority from `Authorities` where Username = ?") // 使用 Username 查詢 Authorities
            .getUserDetailsService();

    jdbcUserDetailsManager.setUserExistsSql("select Username from `Users` where Username = ?"); // 判斷 User 是否存在
    jdbcUserDetailsManager.setCreateUserSql("insert into `Users` (Username, Password, Enabled) values (?,?,?)"); // 插入 User
    jdbcUserDetailsManager.setUpdateUserSql("update `Users` set Password = ?, Enabled = ? where Username = ?"); // 更新 User
    jdbcUserDetailsManager.setDeleteUserSql("delete from `Users` where Username = ?"); // 刪除 User
    jdbcUserDetailsManager.setCreateAuthoritySql("insert into `Authorities` (Username, Authority) values (?,?)"); // 插入 Authorities
    jdbcUserDetailsManager.setDeleteUserAuthoritiesSql("delete from `Authorities` where Username = ?"); // 刪除 Authorities
    jdbcUserDetailsManager.setChangePasswordSql("update `Users` set Password = ? where Username = ?"); // 更新 Authorities

    return jdbcUserDetailsManager;
}
  • org.springframework.security.provisioning.JdbcUserDetailsManager ,繼承 JdbcDaoImpl 的功能,提供了一些很有用的與 Users 和 Authorities 表相關的方法。

  • 胖友先看下 「3. Users」 和 「4. Authorities」 小節,然後回過頭繼續往下看。


SsoHeartbeatHandler ,配置如下:

@Bean
@ConditionalOnMissingBean(SsoHeartbeatHandler.class)
public SsoHeartbeatHandler defaultSsoHeartbeatHandler() {
    return new DefaultSsoHeartbeatHandler();
}
  • 使用 DefaultSsoHeartbeatHandler 實現類,在 「7. SsoHeartbeatHandler」 中,詳細解析。


LogoutHandler ,配置如下:

@Bean
@ConditionalOnMissingBean(LogoutHandler.class)
public LogoutHandler logoutHandler() {
    return new DefaultLogoutHandler();
}
  • 使用 DefaultLogoutHandler 實現類,在 「8. LogoutHandler」 中,詳細解析。

2.2 SpringSecurityConfigureration

@Order(99)
@Profile("auth")
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
static class SpringSecurityConfigurer extends WebSecurityConfigurerAdapter {

    public static final String USER_ROLE = "user";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable(); // 關閉打開的 csrf 保護
        http.headers().frameOptions().sameOrigin(); // 僅允許相同 origin 訪問
        http.authorizeRequests()
                .antMatchers("/openapi/**", "/vendor/**", "/styles/**", "/scripts/**", "/views/**", "/img/**").permitAll() // openapi 和 資源不校驗權限
                .antMatchers("/**").hasAnyRole(USER_ROLE); // 其他,需要登錄 User
        http.formLogin().loginPage("/signin").permitAll().failureUrl("/signin?#/error").and().httpBasic(); // 登錄頁
        http.logout().invalidateHttpSession(true).clearAuthentication(true).logoutSuccessUrl("/signin?#/logout"); // 登出(退出)
        http.exceptionHandling().authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/signin")); // 未身份校驗,跳轉到登錄頁
    }

}
  • @EnableWebSecurity 註解,禁用 Boot 的默認 Security 配置,配合 @Configuration 啓用自定義配置(需要繼承 WebSecurityConfigurerAdapter )。

  • @EnableGlobalMethodSecurity(prePostEnabled = true) 註解,啓用 Security 註解,例如最常用的 @PreAuthorize

  • 注意.antMatchers("/**").hasAnyRole(USER_ROLE); 代碼塊,設置統一的 URL 的權限校驗,只判斷是否爲登陸用戶。另外,#hasAnyRole(...) 方法,會自動添加 "ROLE_" 前綴,所以此處的傳參是 "user" 。代碼如下:

    // ExpressionUrlAuthorizationConfigurer.java
    
    private static String hasAnyRole(String... authorities) {
     String anyAuthorities = StringUtils.arrayToDelimitedString(authorities,
       "','ROLE_");
     return "hasAnyRole('ROLE_" + anyAuthorities + "')";
    }
    

3. Users

Users 表,對應實體 com.ctrip.framework.apollo.portal.entity.po.UserPO ,代碼如下:

@Entity
@Table(name = "Users")
public class UserPO {

    /**
     * 編號
     */
    @Id
    @GeneratedValue
    @Column(name = "Id")
    private long id;
    /**
     * 賬號
     */
    @Column(name = "Username", nullable = false)
    private String username;
    /**
     * 密碼
     */
    @Column(name = "Password", nullable = false)
    private String password;
    /**
     * 郵箱
     */
    @Column(name = "Email", nullable = false)
    private String email;
    /**
     * 是否開啓
     */
    @Column(name = "Enabled", nullable = false)
    private int enabled;
    
}
  • 字段比較簡單,胖友自己看註釋。

3.1 UserInfo

com.ctrip.framework.apollo.portal.entity.bo.UserInfo ,User BO 。代碼如下:

public class UserInfo {

    /**
     * 賬號 {@link com.ctrip.framework.apollo.portal.entity.po.UserPO#username}
     */
    private String userId;
    /**
     * 賬號 {@link com.ctrip.framework.apollo.portal.entity.po.UserPO#username}
     */
    private String name;
    /**
     * 郵箱 {@link com.ctrip.framework.apollo.portal.entity.po.UserPO#email}
     */
    private String email;
    
}
  • 在 UserPO 的 #toUserInfo() 方法中,將 UserPO 轉換成 UserBO ,代碼如下:

    public UserInfo toUserInfo() {
        UserInfo userInfo = new UserInfo();
        userInfo.setName(this.getUsername());
        userInfo.setUserId(this.getUsername());
        userInfo.setEmail(this.getEmail());
        return userInfo;
    }
    
    • 注意userIdname 屬性,都是指向 User.username

4. Authorities

Authorities 表,Spring Security 中的 Authority ,實際和 Role 角色等價。表結構如下:

`Id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增Id',
`Username` varchar(50) NOT NULL,
`Authority` varchar(50) NOT NULL,
  • 目前 Portal 只有一種角色 "ROLE_user" 。如下圖所示:

  • 爲什麼是這樣的呢?在 Apollo 中,

    • 統一的 URL 的權限校驗,只判斷是否爲登陸用戶,在 SpringSecurityConfigureration 中,我們可以看到。

    • 具體每個 URL 的權限校驗,通過在對應的方法上,添加 @PreAuthorize 方法註解,配合具體的方法參數,一起校驗功能 + 數據級的權限校驗。

5. UserService

com.ctrip.framework.apollo.portal.spi.UserService ,User 服務接口,用來給 Portal 提供用戶搜索相關功能。代碼如下:

public interface UserService {

    List<UserInfo> searchUsers(String keyword, int offset, int limit);

    UserInfo findByUserId(String userId);

    List<UserInfo> findByUserIds(List<String> userIds);

}

5.1 SpringSecurityUserService

com.ctrip.framework.apollo.portal.spi.springsecurity.SpringSecurityUserService ,基於 Spring Security 的 UserService 實現類。

5.5.1 構造方法

private PasswordEncoder encoder = new BCryptPasswordEncoder();
/**
 * 默認角色數組,詳細見 {@link #init()}
 */
private List<GrantedAuthority> authorities;

@Autowired
private JdbcUserDetailsManager userDetailsManager;
@Autowired
private UserRepository userRepository;

@PostConstruct
public void init() {
    authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority("ROLE_user"));
}
  • authorities 屬性,只有一個元素,爲 "ROLE_user"

5.5.2 createOrUpdate

#createOrUpdate(UserPO) 方法,創建或更新 User 。代碼如下:

  1: @Transactional
  2: public void createOrUpdate(UserPO user) {
  3:     String username = user.getUsername();
  4:     // 創建 Spring Security User
  5:     User userDetails = new User(username, encoder.encode(user.getPassword()), authorities);
  6:     // 若存在,則進行更新
  7:     if (userDetailsManager.userExists(username)) {
  8:         userDetailsManager.updateUser(userDetails);
  9:     // 若不存在,則進行新增
 10:     } else {
 11:         userDetailsManager.createUser(userDetails);
 12:     }
 13:     // 更新郵箱
 14:     UserPO managedUser = userRepository.findByUsername(username);
 15:     managedUser.setEmail(user.getEmail());
 16:     userRepository.save(managedUser);
 17: }
  • 第 5 行:創建 com.ctrip.framework.apollo.portal.spi.springsecurity.User 對象。

    • 使用  PasswordEncoder 對 password 加密。

    • 傳入對應的角色 authorities 參數。

  • 第 6 至 12 行:新增或更新 User 。

  • 第 13 至 16 行:更新 email 。不直接在【第 6 至 12 行】處理的原因是,com.ctrip.framework.apollo.portal.spi.springsecurity.User 中沒有 email 屬性。

5.5.3 其他實現方法

???? 胖友自己查看代碼。嘿嘿。

5.2 UserInfoController

apollo-portal 項目中,com.ctrip.framework.apollo.portal.controller.UserInfoController ,提供 User 的 API

5.2.1 createOrUpdateUser

用戶管理的界面中,點擊【提交】按鈕,調用創建或更新 User 的 API

創建或更新 User 界面

#createOrUpdateUser(UserPO) 方法,創建或更新 User 。代碼如下:

@Autowired
private UserService userService;

@PreAuthorize(value = "@permissionValidator.isSuperAdmin()")
@RequestMapping(value = "/users", method = RequestMethod.POST)
public void createOrUpdateUser(@RequestBody UserPO user) {
    // 校驗 `username` `password` 非空
    if (StringUtils.isContainEmpty(user.getUsername(), user.getPassword())) {
        throw new BadRequestException("Username and password can not be empty.");
    }
    // 新增或更新 User
    if (userService instanceof SpringSecurityUserService) {
        ((SpringSecurityUserService) userService).createOrUpdate(user);
    } else {
        throw new UnsupportedOperationException("Create or update user operation is unsupported");
    }
}
  • POST /users 接口,Request Body 傳遞 JSON 對象。

  • @PreAuthorize(...) 註解,調用 PermissionValidator#isSuperAdmin() 方法,校驗是否爲超級管理員。後續文章,詳細分享。

  • 調用 SpringSecurityUserService#createOrUpdate(UserPO) 方法,新增或更新 User 。

5.2.2 logout

#logout(request, response) 方法,User 登出。代碼如下:

@Autowired
private LogoutHandler logoutHandler;
    
@RequestMapping(value = "/user/logout", method = RequestMethod.GET)
public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
    logoutHandler.logout(request, response);
}
    • GET /user/logout 接口

  • 調用 LogoutHandler#logout(request, response) 方法,登出 User 。在 「8. LogoutHandler」 中,詳細解析。

6. UserInfoHolder

com.ctrip.framework.apollo.portal.spi.UserInfoHolder ,獲取當前登錄用戶信息,SSO 一般都是把當前登錄用戶信息放在線程 ThreadLocal 上。代碼如下:

public interface UserInfoHolder {

    UserInfo getUser();

}

6.1 SpringSecurityUserInfoHolder

com.ctrip.framework.apollo.portal.spi.springsecurity.SpringSecurityUserInfoHolder ,實現 UserInfoHolder 接口,基於 Spring Security 的 UserInfoHolder 實現類。代碼如下:

public class SpringSecurityUserInfoHolder implements UserInfoHolder {

    @Override
    public UserInfo getUser() {
        // 創建 UserInfo 對象,設置 `username` 到 `UserInfo.userId` 中。
        UserInfo userInfo = new UserInfo();
        userInfo.setUserId(getCurrentUsername());
        return userInfo;
    }

    /**
     * @return username
     */
    private String getCurrentUsername() {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (principal instanceof UserDetails) {
            return ((UserDetails) principal).getUsername();
        }
        if (principal instanceof Principal) {
            return ((Principal) principal).getName();
        }
        return String.valueOf(principal);
    }

}

7. SsoHeartbeatHandler

com.ctrip.framework.apollo.portal.spi.SsoHeartbeatHandler ,Portal 頁面如果長時間不刷新,登錄信息會過期。通過此接口來刷新登錄信息。代碼如下:

public interface SsoHeartbeatHandler {

    void doHeartbeat(HttpServletRequest request, HttpServletResponse response);

}

7.1 DefaultSsoHeartbeatHandler

com.ctrip.framework.apollo.portal.spi.defaultimpl.DefaultSsoHeartbeatHandler ,實現 SsoHeartbeatHandler 接口,代碼如下:

public class DefaultSsoHeartbeatHandler implements SsoHeartbeatHandler {

    @Override
    public void doHeartbeat(HttpServletRequest request, HttpServletResponse response) {
        try {
            response.sendRedirect("default_sso_heartbeat.html");
        } catch (IOException e) {
        }
    }

}
  • 跳轉到 default_sso_heartbeat.html 中。頁面如下:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>SSO Heartbeat</title>
        <script type="text/javascript">
            var reloading = false;
            setInterval(function () {
                if (reloading) {
                    return;
                }
                reloading = true;
                location.reload(true);
            }, 60000);
        </script>
    </head>
    <body>
    </body>
    </html>
    
    • 每 60 秒刷新一次頁面。???? 一臉懵逼,這是幹啥的?繼續往下看。

7.2 SsoHeartbeatController

com.ctrip.framework.apollo.portal.controller.SsoHeartbeatController ,代碼如下:

@Controller
@RequestMapping("/sso_heartbeat")
public class SsoHeartbeatController {

    @Autowired
    private SsoHeartbeatHandler handler;

    @RequestMapping(value = "", method = RequestMethod.GET)
    public void heartbeat(HttpServletRequest request, HttpServletResponse response) {
        handler.doHeartbeat(request, response);
    }

}
  • 通過打開一個新的窗口,訪問 http://ip:prot/sso_hearbeat 地址,每 60 秒刷新一次頁面,從而避免 SSO 登陸過期。因此,相關類的類名都包含 Heartbeat ,代表心跳的意思。

8. LogoutHandler

com.ctrip.framework.apollo.portal.spi.LogoutHandler ,用來實現登出功能。代碼如下:

public interface LogoutHandler {

    void logout(HttpServletRequest request, HttpServletResponse response);

}

8.1 DefaultLogoutHandler

com.ctrip.framework.apollo.portal.spi.defaultimpl.DefaultLogoutHandler ,實現 LogoutHandler 接口,代碼如下:

public class DefaultLogoutHandler implements LogoutHandler {

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response) {
        try {
            response.sendRedirect("/");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}
  • 登出後,跳轉到 / 地址。

  • ???? 在使用 Spring Security 的請款下,不會調用到。注意,因爲,我們配置了登出頁。



歡迎加入我的知識星球,一起探討架構,交流源碼。加入方式,長按下方二維碼噢

已在知識星球更新源碼解析如下:

最近更新《芋道 SpringBoot 2.X 入門》系列,已經 20 餘篇,覆蓋了 MyBatis、Redis、MongoDB、ES、分庫分表、讀寫分離、SpringMVC、Webflux、權限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能測試等等內容。

提供近 3W 行代碼的 SpringBoot 示例,以及超 4W 行代碼的電商微服務項目。

獲取方式:點“在看”,關注公衆號並回復 666 領取,更多內容陸續奉上。

兄弟,一口,點個????

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