1. 概述
老艿艿:本系列假定胖友已经阅读过 《Apollo 官方 wiki 文档》 ,特别是 《Portal 实现用户登录功能》 。
本文分享 Portal 的认证与授权,侧重在认证部分。
在 《Portal 实现用户登录功能》 文档的开头:
Apollo 是配置管理系统,会提供权限管理(Authorization),理论上是不负责用户登录认证功能的实现(Authentication)。 所以 Apollo 定义了一些SPI用来解耦,Apollo 接入登录的关键就是实现这些 SPI 。
和我们理解的 JDK SPI 不同,Apollo 是基于 Spring Profile 的特性,配合上 Spring Java Configuration 实现了类似 SPI 的功能。对于大多数人,我们可能比较熟悉的是,基于不同的 Profile 加载不同环境的 yaml
或 properties
配置文件。所以,当笔者看到这样的玩法,也是眼前一亮。
在 apollo-portal
项目中,spi
包下,我们可以看到认证相关的配置与实现,如下图所示:
com.ctrip.framework.apollo.portal.spi.configuration.AuthConfiguration
,认证 Spring Java 配置。如下图:
目前有三种实现:
profile=ctrip
,携程内部实现,接入了SSO并实现用户搜索、查询接口。profile=auth
,使用 Apollo 提供的 Spring Security 简单认证。profile
为空,使用默认实现,全局只有 apollo 一个账号。一般情况下,我们使用第二种,基于 Spring Security 的实现。所以本文仅分享这种方式。对其他方式感兴趣的胖友,可以自己读下代码哈。
整体类图如下:
UserService ,配置如下:
@Bean
@ConditionalOnMissingBean(UserService.class)
public UserService springSecurityUserService() {
return new SpringSecurityUserService();
}
UserInfoHolder ,配置如下:
@Bean
@ConditionalOnMissingBean(UserInfoHolder.class)
public UserInfoHolder springSecurityUserInfoHolder() {
return new SpringSecurityUserInfoHolder();
}
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 表相关的方法。SsoHeartbeatHandler ,配置如下:
@Bean
@ConditionalOnMissingBean(SsoHeartbeatHandler.class)
public SsoHeartbeatHandler defaultSsoHeartbeatHandler() {
return new DefaultSsoHeartbeatHandler();
}
LogoutHandler ,配置如下:
@Bean
@ConditionalOnMissingBean(LogoutHandler.class)
public LogoutHandler logoutHandler() {
return new DefaultLogoutHandler();
}
@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 + "')"; }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;
}
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;
}
#toUserInfo()
方法中,将 UserPO 转换成 UserBO ,代码如下:
public UserInfo toUserInfo() { UserInfo userInfo = new UserInfo(); userInfo.setName(this.getUsername()); userInfo.setUserId(this.getUsername()); userInfo.setEmail(this.getEmail()); return userInfo; }userId
和 name
属性,都是指向 User.username
。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,
"ROLE_user"
。如下图所示:@PreAuthorize
方法注解,配合具体的方法参数,一起校验功能 + 数据级的权限校验。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);
}
com.ctrip.framework.apollo.portal.spi.springsecurity.SpringSecurityUserService
,基于 Spring Security 的 UserService 实现类。
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"
。#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: }
com.ctrip.framework.apollo.portal.spi.springsecurity.User
对象。password
加密。authorities
参数。email
。不直接在【第 6 至 12 行】处理的原因是,com.ctrip.framework.apollo.portal.spi.springsecurity.User
中没有 email
属性。? 胖友自己查看代码。嘿嘿。
在 apollo-portal
项目中,com.ctrip.framework.apollo.portal.controller.UserInfoController
,提供 User 的 API 。
在用户管理的界面中,点击【提交】按钮,调用创建或更新 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");
}
}
/users
接口,Request Body 传递 JSON 对象。@PreAuthorize(...)
注解,调用 PermissionValidator#isSuperAdmin()
方法,校验是否为超级管理员。后续文章,详细分享。SpringSecurityUserService#createOrUpdate(UserPO)
方法,新增或更新 User 。#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);
}
/user/logout
接口。LogoutHandler#logout(request, response)
方法,登出 User 。在 「8. LogoutHandler」 中,详细解析。com.ctrip.framework.apollo.portal.spi.UserInfoHolder
,获取当前登录用户信息,SSO 一般都是把当前登录用户信息放在线程 ThreadLocal 上。代码如下:
public interface UserInfoHolder {
UserInfo getUser();
}
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);
}
}
com.ctrip.framework.apollo.portal.spi.SsoHeartbeatHandler
,Portal 页面如果长时间不刷新,登录信息会过期。通过此接口来刷新登录信息。代码如下:
public interface SsoHeartbeatHandler {
void doHeartbeat(HttpServletRequest request, HttpServletResponse response);
}
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>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 ,代表心跳的意思。com.ctrip.framework.apollo.portal.spi.LogoutHandler
,用来实现登出功能。代码如下:
public interface LogoutHandler {
void logout(HttpServletRequest request, HttpServletResponse response);
}
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);
}
}
}
/
地址。