安全认证架构演进
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 otherwiseString 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 hackingheaders.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 {@Overridepublic 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)@Documentedpublic @interface Authorize {// allowed consumersString[] value();}
public class AuthorizeInterceptor extends HandlerInterceptorAdapter {@Overridepublic 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}")// @Validatedpublic 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 idpublic 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 userpublic 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 servicepublic 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 microservicepublic 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 servicepublic 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 memberpublic 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 servicepublic 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 systempublic 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 microservicepublic 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 userpublic 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 servicepublic static final String AUTHORIZATION_ICAL_SERVICE = "ical-service";// AUTH ERROR Messagespublic 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";}
用户角色鉴权设计
Auth 3.7 ~ JWT + RBAC

