安全认证架构演进
V1~认证阶段
V1.1~Sticky Session
这里使用Nginx做负载均衡,Nginx在转发流量时有一定的随机性,可能会将用户流量转发到不同的Web服务器实例上,假设某个用户登录的时候,Nginx将流量转发到Server1上,那么对应的Session就在Server1上,后续如果Nginx将用户流量转发到Server2上,但是Server2上没有该用户对应的Session,那么该用户自然会退出登录。
针对这个问题的解决办法就是粘性会话,把用户的Session粘住在某台服务器上,背后的原理就是负载均衡器要截获并记录这个SessionId并且和后台服务器端映射集关联,请求转发到时候会根据这个记录表进行转发。保证用户请求和某台服务器进行绑定。
Nginx支持粘性会话,只需要配置一下即可支持。
这种设计存在的问题:
- 粘性会话会将用户的Session绑定到某台服务器上,如果要对这台服务器进行正常的升级、维护和部署,或者这个服务器发生延迟或者宕机,那么一波用户的会话会瞬间消失,必须重新登录,造成用户体验差。实际上还有另一种问题,一小部分用户反应网站很慢,但是大多数用户是正常的,最好排查下来发现是其中的一台服务器慢,而访问慢的用户会话刚好粘在这台服务器上。
- 第二个问题是扩展性,粘性会话在Web服务器和负载均衡都保存状态,整体是一种有状态架构,随着流量的增长,这些流量同时给Web服务器和负载均衡器带来压力。和无状态的时候相比,这种有状态的系统比较难以扩展。
为了解决粘性会话,主要有以下手段:
- 会话同步复制技术:也就是让会话数据在各个服务器之间进行同步复制,每个服务器上的会话数据都会实时复制到集群中的其他服务器上,这样即使其中的一个服务器宕机了,用户的信息在其他的服务器上仍然存在,前置负载均衡器会自动的切换流量,用户服务不受影响。这个技术可以解决部分稳定性的问题,也不需要粘性会话,但是他会引入复杂性。整个服务集群需要引入复杂的状态同步协议,整体状态扩展性反而会变低。
- 无状态会话:Session数据不存在服务器上,而是存在客户端的浏览器中,通过循环请求捎带传递用户数据,这种可以做到服务器端以及负载均衡器都可以不用存储用户状态,比较容易扩展,这是业界大规模站点常用的会话技术,但是用户数据存在浏览器Cookie中有泄露的风险,所以一般需要加密,另外浏览器对Cookie的大小是有限制的,不能存储较大的用户数据。
集中状态会话:所谓集中状态会话存储就是Session数据集中存储中某个地方,比方可以存在数据库或者缓存中,因为会话存储的性能要求比较高,一般业界采用Redis这种高性能缓存服务器。这种方案可以消除粘性会话,服务器本身也不用存储会话状态,他需要会话的时候通过SessionId去集中存储中去获取就可以了。这样服务器和负载均衡器都可以水平扩展。
V1.5~Centralized Session
集中会话存储扩展也有很多方案,例如Redis开源集群。所以这种方案是一种高性能,可扩展的方案。微服务认证授权
分布式微服务给认证架构带来新的挑战:后台应用和服务众多,如何对每一个服务进行认证和鉴权。传统的用户名和密码以及Session机制还能再适用于微服务场景吗?
- 前端的用户体验多,如果每个都有一套登录认证,显然成本高难以扩展,另外为了避免不必要的重复登录,提升用户体验,需要考虑单点登录SSO。
Auth3.0~Auth Service + Token
该版本把登录认证抽取成一个服务——Auth Service,这个服务统一承担登录认证,会话管理,令牌颁发,校验这些机制。这里还引入了令牌Token作为服务的主要认证。
透明令牌:令牌和Auth Service的登录会话是相关联的,后续可以通过令牌到Auth Service校验会话是否有效,也可以进行查询用户详情。所以这个令牌也称为引用令牌。他本身不包含数据,但是他是Auth Service上用户会话的标识。
该版本的架构操作的问题:每个微服务都要实现部分的认证鉴权的问题,这就给微服务的开发方引入了复杂性,使他们无法聚集于业务逻辑的开发。另外如果让认证鉴权逻辑分布在微服务中,一方面会带来不规范容易出错的问题,另一方面也有潜在的安全风险,比如说有的开发人员会忽略令牌的校验,容易产生安全漏洞。
于是架构团队决定把认证鉴权逻辑放到网关去做。Auth3.5~Token+GateWay
通过引入网关做鉴权认证处理后,大大简化了后台微服务的开发,使得他们能够专注于业务逻辑的开发,同时整体架构也更简单,更规范。另外引入网关统一鉴权以后,对于基于浏览器的客户端应用在登录成功后,可以将Token保存在浏览器中,这样在同一个子域的访问网关可以统一处理,相当于实现类单点登录SSO,这样可以提升用户体验。
此架构一方面解决了微服务安全认证带来的挑战,另一方面也为微服务化业务的成长奠定了基础。Auth3.6~JWT+GateWay:基于JWT的微服务安全网络架构
基于令牌Token的权限校验还是比较重量的,客户端的每次请求都要到Auth Service上进行认证校验,这种架构适合于大部分安全比较严格的微服务,但是当流量比较大时,对Auth Service的访问也比较大,Auth Service可能会成为性能扩展性的瓶颈,需要严格的监控,要做好扩容,投入成本也比较高。另外对于安全敏感不是特别高的应用场景,可以基于无状态的JWT验证。
这一种做法进一步简化了架构,大大的降低了Auth Service的压力,总体上来说是一种高性能、可扩展的架构,适用于大部分对于安全不太敏感的微服务场景。JWT
JWT的原理
JET令牌结构
JWT主要用于认证授权和信息交换这些场景,JWT令牌的结构非常简单,他由以下三个部分构成:
JWT令牌示例
base64Url(Header) + "." + base64Url(Payload) + "." + base64Url(Signature)
JWT的校验
https://jwt.io/
JWT两种主要流程
HMAC流程
RSA流程(更安全)
JWT的优劣
| 优势 | 不足 | | —- | —- | | 紧凑轻量
对AuthServer压力小
简化AuthServer实现 | 无状态和吊销无法两全
传输开销 |
Staffjoy Auth
登录认证
后续访问阶段
JWT的使用
引入JWT生成和校验库
<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.18.1</version>
</dependency>
JWT的生成算法
private static Algorithm getAlgorithm(String signingToken) {
Algorithm algorithm = algorithmMap.get(signingToken);
if (algorithm == null) {
synchronized (algorithmMap) {
algorithm = algorithmMap.get(signingToken);
if (algorithm == null) {
algorithm = Algorithm.HMAC512(signingToken);
algorithmMap.put(signingToken, algorithm);
}
}
}
return algorithm;
}
public static String generateSessionToken(String userId, String signingToken, boolean support, long duration) {
if (StringUtils.isEmpty(signingToken)) {
throw new ServiceException("No signing token present");
}
Algorithm algorithm = getAlgorithm(signingToken);
String token = JWT.create()
.withClaim(CLAIM_USER_ID, userId)
.withClaim(CLAIM_SUPPORT, support)
.withExpiresAt(new Date(System.currentTimeMillis() + duration))
.sign(algorithm);
return token;
}
JWT校验算法
private static Map<String, JWTVerifier> verifierMap = new HashMap<>();
public static DecodedJWT verifySessionToken(String tokenString, String signingToken) {
return verifyToken(tokenString, signingToken);
}
static DecodedJWT verifyToken(String tokenString, String signingToken) {
JWTVerifier verifier = verifierMap.get(signingToken);
if (verifier == null) {
synchronized (verifierMap) {
verifier = verifierMap.get(signingToken);
if (verifier == null) {
Algorithm algorithm = Algorithm.HMAC512(signingToken);
verifier = JWT.require(algorithm).build();
verifierMap.put(signingToken, verifier);
}
}
}
DecodedJWT jwt = verifier.verify(tokenString);
return jwt;
}
登录时种Cookie
public static void loginUser(String userId,
boolean support,
boolean rememberMe,
String signingSecret,
String externalApex,
HttpServletResponse response) {
long duration;
int maxAge;
if (rememberMe) {
// "Remember me"
duration = LONG_SESSION;
} else {
duration = SHORT_SESSION;
}
maxAge = (int) (duration / 1000);
String token = Sign.generateSessionToken(userId, signingSecret, support, duration);
Cookie cookie = new Cookie(AuthConstant.COOKIE_NAME, token);
cookie.setPath("/");
cookie.setDomain(externalApex);
cookie.setMaxAge(maxAge);
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
Cookie中取出JWT令牌
public static String getToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) return null;
Cookie tokenCookie = Arrays.stream(cookies)
.filter(cookie -> AuthConstant.COOKIE_NAME.equals(cookie.getName()))
.findAny().orElse(null);
if (tokenCookie == null) return null;
return tokenCookie.getValue();
}
JWT校验和取出用户会话数据
private Session getSession(HttpServletRequest request) {
String token = Sessions.getToken(request);
if (token == null) return null;
try {
DecodedJWT decodedJWT = Sign.verifySessionToken(token, signingSecret);
String userId = decodedJWT.getClaim(Sign.CLAIM_USER_ID).asString();
boolean support = decodedJWT.getClaim(Sign.CLAIM_SUPPORT).asBoolean();
Session session = Session.builder().userId(userId).support(support).build();
return session;
} catch (Exception e) {
log.error("fail to verify token", "token", token, e);
return null;
}
}
网关传递认证授权信息
private String setAuthHeader(RequestData data, MappingProperties mapping) {
// default to anonymous web when prove otherwise
String authorization = AuthConstant.AUTHORIZATION_ANONYMOUS_WEB;
HttpHeaders headers = data.getHeaders();
Session session = this.getSession(data.getOriginRequest());
if (session != null) {
if (session.isSupport()) {
authorization = AuthConstant.AUTHORIZATION_SUPPORT_USER;
} else {
authorization = AuthConstant.AUTHORIZATION_AUTHENTICATED_USER;
}
this.checkBannedUsers(session.getUserId());
headers.set(AuthConstant.CURRENT_USER_HEADER, session.getUserId());
} else {
// prevent hacking
headers.remove(AuthConstant.CURRENT_USER_HEADER);
}
headers.set(AuthConstant.AUTHORIZATION_HEADER, authorization);
return authorization;
}
登出
public static void logout(String externalApex, HttpServletResponse response) {
Cookie cookie = new Cookie(AuthConstant.COOKIE_NAME, "");
cookie.setPath("/");
cookie.setMaxAge(0);
cookie.setDomain(externalApex);
response.addCookie(cookie);
}
认证上下文助手类
private static String getRequetHeader(String headerName) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();
String value = request.getHeader(headerName);
return value;
}
return null;
}
public static String getUserId() {
return getRequetHeader(AuthConstant.CURRENT_USER_HEADER);
}
public static String getAuthz() {
return getRequetHeader(AuthConstant.AUTHORIZATION_HEADER);
}
Feign客户端传递用户认证信息
public class FeignRequestHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
String userId = AuthContext.getUserId();
if (!StringUtils.isEmpty(userId)) {
requestTemplate.header(AuthConstant.CURRENT_USER_HEADER, userId);
}
}
}
服务调用鉴权
服务间调用授权截获器
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Authorize {
// allowed consumers
String[] value();
}
public class AuthorizeInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Authorize authorize = handlerMethod.getMethod().getAnnotation(Authorize.class);
if (authorize == null) {
return true; // no need to authorize
}
String[] allowedHeaders = authorize.value();
String authzHeader = request.getHeader(AuthConstant.AUTHORIZATION_HEADER);
if (StringUtils.isEmpty(authzHeader)) {
throw new PermissionDeniedException(AuthConstant.ERROR_MSG_MISSING_AUTH_HEADER);
}
if (!Arrays.asList(allowedHeaders).contains(authzHeader)) {
throw new PermissionDeniedException(AuthConstant.ERROR_MSG_DO_NOT_HAVE_ACCESS);
}
return true;
}
}
控制器调用鉴权
@PutMapping(path = "/update")
@Authorize(value = {
AuthConstant.AUTHORIZATION_WWW_SERVICE,
AuthConstant.AUTHORIZATION_COMPANY_SERVICE,
AuthConstant.AUTHORIZATION_AUTHENTICATED_USER,
AuthConstant.AUTHORIZATION_SUPPORT_USER,
AuthConstant.AUTHORIZATION_SUPERPOWERS_SERVICE
})
public GenericAccountResponse updateAccount(@RequestBody @Valid AccountDto newAccountDto) {
this.validateAuthenticatedUser(newAccountDto.getId());
this.validateEnv();
AccountDto accountDto = accountService.update(newAccountDto);
GenericAccountResponse genericAccountResponse = new GenericAccountResponse(accountDto);
return genericAccountResponse;
}
@PutMapping(path = "/update_password")
@Authorize(value = {
AuthConstant.AUTHORIZATION_WWW_SERVICE,
AuthConstant.AUTHORIZATION_AUTHENTICATED_USER,
AuthConstant.AUTHORIZATION_SUPPORT_USER
})
public BaseResponse updatePassword(@RequestBody @Valid UpdatePasswordRequest request) {
this.validateAuthenticatedUser(request.getUserId());
accountService.updatePassword(request.getUserId(), request.getPassword());
BaseResponse baseResponse = new BaseResponse();
baseResponse.setMessage("password updated");
return baseResponse;
}
用户角色和环境鉴权
private void validateAuthenticatedUser(String userId) {
if (AuthConstant.AUTHORIZATION_AUTHENTICATED_USER.equals(AuthContext.getAuthz())) {
String currentUserId = AuthContext.getUserId();
if (StringUtils.isEmpty(currentUserId)) {
throw new ServiceException("failed to find current user id");
}
if (!userId.equals(currentUserId)) {
throw new PermissionDeniedException("You do not have access to this service");
}
}
}
private void validateEnv() {
if (AuthConstant.AUTHORIZATION_SUPERPOWERS_SERVICE.equals(AuthContext.getAuthz())) {
if (!EnvConstant.ENV_DEV.equals(this.envConfig.getName())) {
logger.warn("Development service trying to connect outside development environment");
throw new PermissionDeniedException("This service is not available outside development environments");
}
}
}
API Client传递服务调用方
@FeignClient(name = AccountConstant.SERVICE_NAME, path = "/v1/account", url = "${staffjoy.account-service-endpoint}")
// @Validated
public interface AccountClient {
@PostMapping(path = "/create")
GenericAccountResponse createAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid CreateAccountRequest request);
@PostMapping(path = "/track_event")
BaseResponse trackEvent(@RequestBody @Valid TrackEventRequest request);
@PostMapping(path = "/sync_user")
BaseResponse syncUser(@RequestBody @Valid SyncUserRequest request);
@GetMapping(path = "/list")
ListAccountResponse listAccounts(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam int offset, @RequestParam @Min(0) int limit);
// GetOrCreate is for internal use by other APIs to match a user based on their phonenumber or email.
@PostMapping(path= "/get_or_create")
GenericAccountResponse getOrCreateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid GetOrCreateRequest request);
@GetMapping(path = "/get")
GenericAccountResponse getAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @NotBlank String userId);
@PutMapping(path = "/update")
GenericAccountResponse updateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid AccountDto newAccount);
@GetMapping(path = "/get_account_by_phonenumber")
GenericAccountResponse getAccountByPhonenumber(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @PhoneNumber String phoneNumber);
@PutMapping(path = "/update_password")
BaseResponse updatePassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid UpdatePasswordRequest request);
@PostMapping(path = "/verify_password")
GenericAccountResponse verifyPassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid VerifyPasswordRequest request);
// RequestPasswordReset sends an email to a user with a password reset link
@PostMapping(path = "/request_password_reset")
BaseResponse requestPasswordReset(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid PasswordResetRequest request);
@PostMapping(path = "/request_email_change")
BaseResponse requestEmailChange(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailChangeRequest request);
// ChangeEmail sets an account to active and updates its email. It is
// used after a user clicks a confirmation link in their email.
@PostMapping(path = "/change_email")
BaseResponse changeEmail(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailConfirmation request);
}
授权Header定义
public class AuthConstant {
public static final String COOKIE_NAME = "staffjoy-faraday";
// header set for internal user id
public static final String CURRENT_USER_HEADER = "faraday-current-user-id";
// AUTHORIZATION_HEADER is the http request header
// key used for accessing the internal authorization.
public static final String AUTHORIZATION_HEADER = "Authorization";
// AUTHORIZATION_ANONYMOUS_WEB is set as the Authorization header to denote that
// a request is being made bu an unauthenticated web user
public static final String AUTHORIZATION_ANONYMOUS_WEB = "faraday-anonymous";
// AUTHORIZATION_COMPANY_SERVICE is set as the Authorization header to denote
// that a request is being made by the company service
public static final String AUTHORIZATION_COMPANY_SERVICE = "company-service";
// AUTHORIZATION_BOT_SERVICE is set as the Authorization header to denote that
// a request is being made by the bot microservice
public static final String AUTHORIZATION_BOT_SERVICE = "bot-service";
// AUTHORIZATION_ACCOUNT_SERVICE is set as the Authorization header to denote that
// a request is being made by the account service
public static final String AUTHORIZATION_ACCOUNT_SERVICE = "account-service";
// AUTHORIZATION_SUPPORT_USER is set as the Authorization header to denote that
// a request is being made by a Staffjoy team member
public static final String AUTHORIZATION_SUPPORT_USER = "faraday-support";
// AUTHORIZATION_SUPERPOWERS_SERVICE is set as the Authorization header to
// denote that a request is being made by the dev-only superpowers service
public static final String AUTHORIZATION_SUPERPOWERS_SERVICE = "superpowers-service";
// AUTHORIZATION_WWW_SERVICE is set as the Authorization header to denote that
// a request is being made by the www login / signup system
public static final String AUTHORIZATION_WWW_SERVICE = "www-service";
// AUTH_WHOAMI_SERVICE is set as the Authorization heade to denote that
// a request is being made by the whoami microservice
public static final String AUTHORIZATION_WHOAMI_SERVICE = "whoami-service";
// AUTHORIZATION_AUTHENTICATED_USER is set as the Authorization header to denote that
// a request is being made by an authenticated we6b user
public static final String AUTHORIZATION_AUTHENTICATED_USER = "faraday-authenticated";
// AUTHORIZATION_ICAL_SERVICE is set as the Authorization header to denote that
// a request is being made by the ical service
public static final String AUTHORIZATION_ICAL_SERVICE = "ical-service";
// AUTH ERROR Messages
public static final String ERROR_MSG_DO_NOT_HAVE_ACCESS = "You do not have access to this service";
public static final String ERROR_MSG_MISSING_AUTH_HEADER = "Missing Authorization http header";
}