一、功能说明
认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。
具体服务:
[gateway-service]:网关服务,负责请求转发和鉴权功能,整合JWT;
[auth-service]:认证服务,负责对登录用户进行认证,整合JWT;
[user-service]:受保护的API服务,用户鉴权通过后可以访问该服务,不整合JWT;
架构图如下:
二、授权服务module[auth-service]
2.1 新建module[auth-service]
1、选中ac-mall-cloud项目,选择新建module
2、选择maven工程
3、填写module名称
4、完成效果
2.2 maven配置
引入redis、jwt等依赖,module [auth-service]pom.xml全配置如下
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.9.RELEASE</version>
<relativePath/>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>org.ac</groupId>
<artifactId>auth-service</artifactId>
<properties>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
<alibaba.cloud.version>2.1.0.RELEASE</alibaba.cloud.version>
</properties>
<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>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${alibaba.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
</dependencies>
</project>
2.3 创建启动类AuthServiceApplication
新建com.ac.auth
包,并创建SpringBoot启动类AuthServiceApplication
@EnableDiscoveryClient
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
}
2.4 新建application.yml配置文件
1、配置启动端口6010
2、配置Nacos服务注册中心
3、配置Redis
server:
port: 6010
spring:
application:
name: auth-service
cloud:
nacos:
discovery:
server-addr: 47.105.146.74:8848
redis:
database: 0
host: 39.108.250.186
port: 6379
password: xxxxx
jedis:
pool:
max-active: 500 #连接池的最大数据库连接数。设为0表示无限制
max-idle: 20 #最大空闲数
max-wait: -1
min-idle: 5
timeout: 1000
2.5 token授权代码
新建com.ac.auth.util
包,并创建JWTUtil类
import com.ac.auth.response.ResponseCodeEnum;
import com.ac.auth.response.TokenAuthenticationException;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
public class JWTUtil {
public static final long TOKEN_EXPIRE_TIME = 7200 * 1000;
private static final String ISSUER = "ac";
public static String generateToken(String userId,String username, String secretKey) {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
Date now = new Date();
Date expireTime = new Date(now.getTime() + TOKEN_EXPIRE_TIME);
String token = JWT.create()
.withIssuer(ISSUER)
.withIssuedAt(now)
.withExpiresAt(expireTime)
.withClaim("userId", userId)
.withClaim("username", username)
.sign(algorithm);
return token;
}
public static void verifyToken(String token, String secretKey) {
try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
JWTVerifier jwtVerifier = JWT.require(algorithm).withIssuer(ISSUER).build();
jwtVerifier.verify(token);
} catch (JWTDecodeException jwtDecodeException) {
throw new TokenAuthenticationException(ResponseCodeEnum.TOKEN_INVALID.getCode(), ResponseCodeEnum.TOKEN_INVALID.getMessage());
} catch (SignatureVerificationException signatureVerificationException) {
throw new TokenAuthenticationException(ResponseCodeEnum.TOKEN_SIGNATURE_INVALID.getCode(), ResponseCodeEnum.TOKEN_SIGNATURE_INVALID.getMessage());
} catch (TokenExpiredException tokenExpiredException) {
throw new TokenAuthenticationException(ResponseCodeEnum.TOKEN_EXPIRED.getCode(), ResponseCodeEnum.TOKEN_INVALID.getMessage());
} catch (Exception ex) {
throw new TokenAuthenticationException(ResponseCodeEnum.UNKNOWN_ERROR.getCode(), ResponseCodeEnum.UNKNOWN_ERROR.getMessage());
}
}
public static String getUserName(String token) {
try{
DecodedJWT decodedJWT = JWT.decode(token);
String username = decodedJWT.getClaim("username").asString();
return username;
}catch (Exception e){
e.printStackTrace();
}
return null;
}
public static String getUserId(String token) {
try {
DecodedJWT decodedJWT = JWT.decode(token);
String userId = decodedJWT.getClaim("userId").asString();
return userId;
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}
新建com.ac.auth.response
包,并创建LoginResponse、ResponseCodeEnum、ResponseResult、TokenAuthenticationException类
@Data
public class LoginResponse {
private String token;
private String refreshToken;
private String userId;
private String username;
}
public enum ResponseCodeEnum {
SUCCESS(0, "成功"),
FAIL(-1, "失败"),
LOGIN_ERROR(1000, "用户名或密码错误"),
UNKNOWN_ERROR(2000, "未知错误"),
PARAMETER_ILLEGAL(2001, "参数不合法"),
TOKEN_INVALID(2002, "无效的Token"),
TOKEN_SIGNATURE_INVALID(2003, "无效的签名"),
TOKEN_EXPIRED(2004, "token已过期"),
TOKEN_MISSION(2005, "token缺失"),
REFRESH_TOKEN_INVALID(2006, "刷新Token无效");
private int code;
private String message;
ResponseCodeEnum(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
public class ResponseResult<T> {
private int code = 0;
private String msg;
private T data;
public ResponseResult(int code, String msg) {
this.code = code;
this.msg = msg;
}
public ResponseResult(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public static ResponseResult success() {
return new ResponseResult(ResponseCodeEnum.SUCCESS.getCode(), ResponseCodeEnum.SUCCESS.getMessage());
}
public static <T> ResponseResult<T> success(T data) {
return new ResponseResult(ResponseCodeEnum.SUCCESS.getCode(), ResponseCodeEnum.SUCCESS.getMessage(), data);
}
public static ResponseResult error(int code, String msg) {
return new ResponseResult(code, msg);
}
public static <T> ResponseResult<T> error(int code, String msg, T data) {
return new ResponseResult(code, msg, data);
}
public boolean isSuccess() {
return code == 0;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
public class TokenAuthenticationException extends RuntimeException {
public TokenAuthenticationException() {
super();
}
public TokenAuthenticationException(int code, String message) {
super(code + message);
}
}
新建com.ac.auth.request
包,并创建LoginRequest、RefreshRequest类
@Data
public class LoginRequest {
private String username;
private String password;
}
@Data
public class RefreshRequest {
private String userId;
private String refreshToken;
}
新建com.ac.auth.controller
包,并创建LoginController类
import com.ac.auth.request.LoginRequest;
import com.ac.auth.request.RefreshRequest;
import com.ac.auth.response.LoginResponse;
import com.ac.auth.response.ResponseCodeEnum;
import com.ac.auth.response.ResponseResult;
import com.ac.auth.util.JWTUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/auth")
public class LoginController {
@Value("${secretKey:123456}")
private String secretKey;
@Autowired
private StringRedisTemplate stringRedisTemplate;
final static String TOKEN = "token";
final static String REFRESH_TOKEN = "refreshToken";
@PostMapping("/login")
public ResponseResult login(@RequestBody @Validated LoginRequest request, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return ResponseResult.error(ResponseCodeEnum.PARAMETER_ILLEGAL.getCode(), ResponseCodeEnum.PARAMETER_ILLEGAL.getMessage());
}
String username = request.getUsername();
String password = request.getPassword();
// 假设查询到用户ID是1001
String userId = "1001";
if ("alanchen".equals(username) && "admin".equals(password)) {
// 生成Token
String token = JWTUtil.generateToken(userId,username,secretKey);
// 生成刷新Token
String refreshToken = UUID.randomUUID().toString().replace("-", "");
// 放入缓存
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
String key = userId;
hashOperations.put(key, TOKEN, token);
hashOperations.put(key, REFRESH_TOKEN, refreshToken);
stringRedisTemplate.expire(key, JWTUtil.TOKEN_EXPIRE_TIME, TimeUnit.MILLISECONDS);
LoginResponse loginResponse = new LoginResponse();
loginResponse.setToken(token);
loginResponse.setRefreshToken(refreshToken);
loginResponse.setUserId(userId);
loginResponse.setUsername(username);
return ResponseResult.success(loginResponse);
}
return ResponseResult.error(ResponseCodeEnum.LOGIN_ERROR.getCode(), ResponseCodeEnum.LOGIN_ERROR.getMessage());
}
@GetMapping("/logout")
public ResponseResult logout(@RequestParam("userId") String userId) {
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
String key = userId;
hashOperations.delete(key, TOKEN);
return ResponseResult.success();
}
@PostMapping("/refreshToken")
public ResponseResult refreshToken(@RequestBody @Validated RefreshRequest request, BindingResult bindingResult) {
String userId = request.getUserId();
//通过userId去数据库查到userName
String userName="alanchen";
String refreshToken = request.getRefreshToken();
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
String key = userId;
String originalRefreshToken = hashOperations.get(key, REFRESH_TOKEN);
if (StringUtils.isBlank(originalRefreshToken) || !originalRefreshToken.equals(refreshToken)) {
return ResponseResult.error(ResponseCodeEnum.REFRESH_TOKEN_INVALID.getCode(), ResponseCodeEnum.REFRESH_TOKEN_INVALID.getMessage());
}
// 生成新token
String newToken = JWTUtil.generateToken(userId,userName,secretKey);
hashOperations.put(key, TOKEN, newToken);
stringRedisTemplate.expire(userId, JWTUtil.TOKEN_EXPIRE_TIME, TimeUnit.MILLISECONDS);
return ResponseResult.success(newToken);
}
}
2.6 项目结构
三、网关服务module[gateway-service]
我们需要在网关服务module[gateway-service]中加入权限校验代码,客户端通过网关访问微服务前,都必须先通过网关服务的鉴权。
3.1 maven配置
添加redis、jwt等依赖,module [gateway-service]pom.xml全配置如下
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.9.RELEASE</version>
<relativePath/>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>org.ac</groupId>
<artifactId>gateway-service</artifactId>
<properties>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
<alibaba.cloud.version>2.1.0.RELEASE</alibaba.cloud.version>
</properties>
<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>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${alibaba.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
</dependencies>
</project>
3.2 添加[auth-service]服务网关路由配置
在[gateway-service]application.yml中添加[auth-service]服务网关路由配置,如下
- id: auth-service-api
uri: lb://auth-service
predicates:
- Path=/auth/**
[gateway-service]application.yml 完整配置如下
server:
port: 7010
spring:
application:
name: gateway-service
redis:
database: 0
host: 39.108.250.186
port: 6379
password: xxxxx
jedis:
pool:
max-active: 500 #连接池的最大数据库连接数。设为0表示无限制
max-idle: 20 #最大空闲数
max-wait: -1
min-idle: 5
timeout: 1000
cloud:
nacos:
discovery:
server-addr: 47.105.146.74:8848
gateway:
routes: # 路由数组[路由 就是指定当请求满足什么条件的时候转到哪个微服务]
- id: user-service-api # 当前路由的标识, 要求唯一
uri: lb://user-service # lb指的是从nacos中按照名称获取微服务,并遵循负载均衡策略
predicates: # 断言(就是路由转发要满足的条件)
- Path=/users/** # 当请求路径满足Path指定的规则时,才进行路由转发
#filters: # 过滤器,请求在传递过程中可以通过过滤器对其进行一定的修改
#- StripPrefix=1 # 转发之前去掉1层路径
- id: order-service-api
uri: lb://order-service
predicates:
- Path=/orders/**
- id: auth-service-api
uri: lb://auth-service
predicates:
- Path=/auth/**
- id: qq_route
uri: https://www.qq.com
predicates:
- Query=url,qq
3.3 加入token相关代码
因为鉴权时也需要用token,因此[gateway-service]同样需要token相关的代码,我们可以将[auth-service]服务中response
包和util
包下的代码直接复制过来。如果觉得代码重复,也可以新建一个权限相关的公共module,让后让[auth-service]、[gateway-service]都依赖它。也可以将这些重复的代码打成一个jar包,让[auth-service]、[gateway-service]引入。
3.4 权限(token)校验代码
新建auth
包,创建TokenFilter类
import com.ac.gateway.response.ResponseCodeEnum;
import com.ac.gateway.response.ResponseResult;
import com.ac.gateway.response.TokenAuthenticationException;
import com.ac.gateway.util.JWTUtil;
import com.alibaba.fastjson.JSON;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Component
public class TokenFilter implements GlobalFilter, Ordered {
@Value("${secretKey:123456}")
private String secretKey;
@Autowired
private StringRedisTemplate stringRedisTemplate;
final static String TOKEN = "token";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
ServerHttpResponse serverHttpResponse = exchange.getResponse();
String uri = serverHttpRequest.getURI().getPath();
// 检查白名单(配置)
if (uri.indexOf("/auth/login") >= 0) {
return chain.filter(exchange);
}
String token = serverHttpRequest.getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(token)) {
serverHttpResponse.setStatusCode(HttpStatus.UNAUTHORIZED);
return getVoidMono(serverHttpResponse, ResponseCodeEnum.TOKEN_MISSION);
}
String userId = JWTUtil.getUserId(token);
if(userId == null){
return getVoidMono(serverHttpResponse, ResponseCodeEnum.TOKEN_INVALID);
}
// 检查Redis中是否有此Token(退出登录有删除token)
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
String redisToken = hashOperations.get(userId, TOKEN);
if (!token.equals(redisToken)) {
return getVoidMono(serverHttpResponse, ResponseCodeEnum.TOKEN_INVALID);
}
try {
JWTUtil.verifyToken(token, secretKey);
} catch (TokenAuthenticationException ex) {
return getVoidMono(serverHttpResponse, ResponseCodeEnum.TOKEN_INVALID);
} catch (Exception ex) {
return getVoidMono(serverHttpResponse, ResponseCodeEnum.UNKNOWN_ERROR);
}
ServerHttpRequest mutableReq = serverHttpRequest.mutate().header("userId", userId).build();
ServerWebExchange mutableExchange = exchange.mutate().request(mutableReq).build();
return chain.filter(mutableExchange);
}
private Mono<Void> getVoidMono(ServerHttpResponse serverHttpResponse, ResponseCodeEnum responseCodeEnum) {
serverHttpResponse.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
ResponseResult responseResult = ResponseResult.error(responseCodeEnum.getCode(), responseCodeEnum.getMessage());
DataBuffer dataBuffer = serverHttpResponse.bufferFactory().wrap(JSON.toJSONString(responseResult).getBytes());
return serverHttpResponse.writeWith(Flux.just(dataBuffer));
}
@Override
public int getOrder() {
return -100;
}
}
四、测试
1、依次启动[user-service]、[auth-service]、[gateway-service]
2、先直接访问[user-service]的获取用户接口是否正常
3、通过[gateway-service]访问[user-service]的获取用户接口
权限校验起效果了,由于没有传合法的token,因此访问接口失败
4、通过[gateway-service]访问[auth-service],获取合法的token
5、加上token,重新通过[gateway-service]访问[user-service]的获取用户接口
五、问题补充
5.1 怎么防止客户端直接访问微服务
我们都知道网关适合做认证和鉴权,但是在安全层面,我们要求更严格的权限,对于有些项目来说,本身网络跟外部隔离,再加上其它的安全手段,所以我们只要求在网关上鉴权就可以了。
具体来说,微服务[user-service]所在服务器设置防火墙限定IP和端口只能由[gateway-service]来访问,客户端所有请求,都先通过[gateway-service]的权限校验才能转发访问到[user-service]接口
5.2 服务与服务之前的权限怎么控制?
一般情况下,服务与服务之间可以直接调用,不需要加权限控制。但有些项目权限控制要求比较高,要求服务对服务之间的调用进行鉴权,知道某个用户是否有权限调用某个接口,这些都需要进行鉴权,这时的方案如下。
1、在Gateway网关层做认证,通过用户校验后,传递用户信息到Header中,后台服务在收到Header后进行解析,解析完后查看是否有调用此服务或者某个url的权限,然后完成鉴权。
2、从服务内部发出的请求,在出去时进行拦截,把用户信息保存在Header里,然后传出去,被调用方取到Header后进行解析和鉴权。