以注册、登录为主线,串联起验证码生成及校验、邮件发送、IP防暴刷、用户统一认证等功能。
实现需基于Spring Cloud 微服务架构,技术涉及Nginx、Eureka、Feign(Ribbon、Hystrix)、Gateway、Config+Bus等。
1)用户访问到登录页面,在登录页面中有注册新账号功能
2)点击“注册新账号“,跳转到注册页面
3)在注册页面,需要用户输入邮箱地址、密码、确认密码,然后点击”获取验证码“,系统会生成验证码并向所输入的邮箱地址发送该验证码,用户拿到邮箱中的验证码输入后完成注册
规则如下
A:一分钟内只允许获取一次验证码(前端Js控制即可),验证码为随机生成的6位数字,10分钟内有效,验证码存储到mysql数据库中(也可以选择存入到Redis中);
B:存储到mysql数据库之后,使用发邮件功能,将该验证码发送到所输入的邮箱地址中
C:用户从邮箱中拿到验证码,点击注册时,需要进行行校验,因为验证码已经存入数据库,此时只需要查询数据库中该邮箱地址对应的最近一次的验证码记录,校验验证码是否正确,是否超时,若有问题,准确提示给用户
4)注册成功后,根据 <用户邮箱+密码> 生成签发token令牌(此处生成一个UUID模拟即可),该token令牌存入数据库(也可以选择存入到Redis中),并写入cookie中(以后的每次请求都会在cookie中携带该token,网关过滤器通过验证token的合法性来确定用户请求是否合法,如果token合法,根据token取出用户信息---->邮箱),最后重定向到欢迎⻚页面(显示邮箱地址)
1)用户访问登录页面,在登录页面输入邮箱地址+密码
2)点击登录,后台对用户名和密码进行验证,然后根据<用户邮箱+密码> 生成签发token令牌(此处生成一个UUID模拟即可),该token令牌存入数据库(因为大家未系统学习Redis,所以此处令牌存入数据库即可),并写入cookie中(以后的每次请求都会在cookie中携带该token,网关过滤器通过验证token的合法性来确定用户请求是否合法,如果token合法,根据token取出用户信息---->邮箱),最后重定向到欢迎页面(显示邮箱地址)
Nginx
占⽤用端⼝口:80
实现动静分离。将静态资源 html 页面存放至本地磁盘,数据请求统一经过 GateWay 网关路由到下游微服务。
静态资源(html页面)
访问前缀:/static/xxx.html
包括登录页面 login.html、注册页面 register.html、以及成功登录之后的欢迎页面welcome.html,各个页面细节元素后面有描述
GateWay 网关
占用端口:9002端口 数据请求前缀:/api/xxx
完成统一路由、IP防暴刷(限制单个客户端IP在最近X分钟内请求注册接口不能超过Y次)、统一认证(登录时验证用户名密码是否合法,合法调用用户微服务生成token,写入cookie,并且携带邮箱地址重定向到欢迎页面;后续请求再到来时,验证客户端请求cookie中携带的token是否合法,合法则放行,此处不考虑token更新问题)等功能
路路径路路由规则:
/api/user/** 路由到用户微服务
/api/code/** 路由到验证码微服务
/api/email/** 路由到邮件微服务
dabing-service-user 用户微服务
占用端口:8080 数据请求前缀:/api/user/**
提供注册接口、用户是否已注册接口、登录接口(⽣生成token并入库,token写入cookie中)、查
询用户登录邮箱接口等
dabing-service-code 验证码微服务
占用端口:8081 数据请求前缀:/api/code/**
用于提供验证码生成、验证码校验等接口,同时调用邮件微服务发送验证码
dabing-service-email 邮件微服务
占用端口:8082 数据请求前缀:/api/email/**
提供邮件发送功能,用于将生成的验证码发送到注册邮箱
Spring Cloud Config+Bus
占用端口: 9006
共享的配置:数据库连接信息、邮件发送相关配置、IP防暴暴刷指标参数(X分钟的X,Y上限的Y)
注意:除去Eureka是2个实例的集群模式,其他保持单实例
涉及到的微服务名称定义、接口定义、参数名称,可以和下文提到的保持一致。
微服务名称 | API 接口 | 返回值 | 接口描述 |
---|---|---|---|
dabing-service-use | /user/register/{email}/{password}/{code} | true/false | 注册接⼝口,true成功,false失败 |
/user/isRegistered/{email} | true/false | 是否已注册,根据邮箱判断,true代表已经注册过,false代表尚未注册 | |
/user/login/{email}/{password} | 邮箱地址 | 登录接⼝口,验证⽤用户名密码合法性,根据⽤用户名和密码⽣生成token,token存⼊入数据库,并写⼊入cookie中,登录成功返回邮箱地址,重定向到欢迎⻚页 | |
/user/info/{token} | 根据token查询⽤用户登录邮箱接⼝口 | ||
dabing-service-code | /code/create/{email} | true/false | ⽣生成验证码并发送到对应邮箱,成功true,失败false |
/code/validate/{email}/{code} | 0/1/2 | 校验验证码是否正确,0正确1错误2超时 | |
dabing-service-email | /email/{email}/{code} | true/false | 发送验证码到邮箱,true成功,false失败 |
涉及到界面的,界面样式不做要求,界面元素完备即可。
登录界面 login.html
登录界面包含输入项:
邮箱:email
密码:password
点击“登录”调用登录接口,成功后重定向到欢迎页面welcome.html
ajax调用http://localhost:9002/api/user/login/{email}/{password}
点击“注册新账号”可跳转到注册页面register.html
注册界面 register.html
注册界面包含输入项:
邮箱:email
密码: password
确认密码:ConfirmPassword
验证码:code
点击“获取验证码”:调用获取验证码接口 http://localhost:9002/code/create/{email}
点击”注册“:调用注册接口 http://localhost:9002/user/register/{email}/{password}/{code}
涉及到两个数据表:验证码存储表+令牌存储表,数据表参考如下
验证码存储表
-- ----------------------------
-- Table structure for dabing_auth_code
-- ----------------------------
DROP TABLE IF EXISTS `dabing_auth_code`;
CREATE TABLE `dabing_auth_code` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '⾃自增主键',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱地址',
`code` varchar(6) DEFAULT NULL COMMENT '验证码',
`createtime` datetime DEFAULT NULL COMMENT '创建时间',
`expiretime` datetime DEFAULT NULL COMMENT '过期时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
SET FOREIGN_KEY_CHECKS = 1;
令牌存储表
-- ----------------------------
-- Table structure for dabing_token
-- ----------------------------
DROP TABLE IF EXISTS `dabing_token`;
CREATE TABLE `dabing_token` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '⾃自增主键',
`email` varchar(64) NOT NULL COMMENT '邮箱地址',
`token` varchar(255) NOT NULL COMMENT '令牌',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
SET FOREIGN_KEY_CHECKS = 1;
前文在 Html 静态页面中有ajax 请求数据API统一接口9002的地方,会涉及跨域问题,可以考虑将静态
资源和数据请求接口放在同一个域名下,根据url前缀在nginx层进行区分。
比如所有部署,包括nginx都在一台机器上
可以给机器设置一个域名 www.dabing.com
静态资源访问 www.dabing.com/static/xxx.html
数据API接口请求 www.dabing.com/api/xxx/yyy
通过/static和/api在nginx层进行区分
注册新账号
一分钟内只允许获取一次验证码
发邮件功能
校验验证码
验证码超时展示
保存令牌数据库
令牌保存cookie中
跳转到欢迎页面
登录
生成Token保存到令牌表和Cookies中最后转到欢迎页面
未登录状态网关拦截
IP防暴刷过滤器:在1分钟内注册超过100次时返回错误信息
@Component
@RefreshScope // 刷新配置信息
public class IpGlobalFilter implements GlobalFilter, Ordered {
@Value("${filter.limit.ip.uri}")
private String limitUri;
@Value("${filter.limit.ip.maxtimes}")
private int maxTimes;
@Value("${filter.limit.ip.limitminutes}")
private int limitMinutes;
private static ConcurrentMap<String, List<Long>> ipCache = new ConcurrentHashMap<>();
public IpGlobalFilter() {
System.out.println("IpGlobalFilter初始化");
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
System.out.println(maxTimes);
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
// 客户端IP
String ip = request.getRemoteAddress().getHostString();
String path = request.getURI().getPath();
// 如果请求的服务未设限,直接放行
if (!path.startsWith(limitUri)) {
return chain.filter(exchange);
}
// 设限服务把本次请求加入缓存
List<Long> currentIpCache = ipCache.get(ip);
// 初始化当前ip请求记录
if (currentIpCache == null) {
currentIpCache = new ArrayList<>();
ipCache.put(ip, currentIpCache);
}
currentIpCache.add(System.currentTimeMillis());
// 计算limitMinutes内访问次数是否超过maxTimes
int count = 0;
long startTime = System.currentTimeMillis() - (limitMinutes * 60 * 1000);
for (Long reqTime : currentIpCache) {
if (reqTime > startTime) {
count++;
}
}
if (count > maxTimes) {
response.setStatusCode(HttpStatus.FORBIDDEN);
String data = "您频繁进⾏注册,请求已被拒绝!";
DataBuffer wrap = response.bufferFactory().wrap(data.getBytes());
return response.writeWith(Mono.just(wrap));
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
@Component
public class TokenGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private UserFeignClient userFeignClient;
/**
* 进⾏token的验证,⽤户微服务和验证码微服务的请求不过滤(⽹关调⽤下游⽤户微服务的token验证接⼝)
* @param exchange
* @param chain
* @return
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String path = request.getURI().getPath();
// 用户微服务和验证码微服务的请求不过滤
if (path.startsWith("/api/user") || path.startsWith("/api/code")) {
return chain.filter(exchange);
}
// 获取Cookie,token不存在或者用户微服务查询不到重定向到登录页面
List<HttpCookie> cookies = request.getCookies().get("token");
if (!CollectionUtils.isEmpty(cookies)) {
HttpCookie cookie = cookies.get(0);
String token = cookie.getValue();
if (!"".equals(userFeignClient.info(token))) {
return chain.filter(exchange);
}
}
// 返回状态码 303,重定向到登录页面
response.getHeaders().set(HttpHeaders.LOCATION, "/static/login.html");
response.setStatusCode(HttpStatus.SEE_OTHER);
return response.setComplete();
}
@Override
public int getOrder() {
return 0;
}
}
server:
port: 9090
eureka:
client:
serviceUrl:
defaultZone: http://dabingcloudeurekaservera:8761/eureka/,http://dabingcloudeurekaserverb:8762/eureka/ #把 eureka 集群中的所有 url 都填写了进来,也可以只写一台,因为各个 eureka server 可以同步注册表
instance:
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${spring.application.name}:${server.port}:@project.version@
spring:
application:
name: dabing-cloud-gateway
cloud:
gateway:
routes:
- id: service-code-router
uri: lb://dabing-service-code
predicates:
- Path=/api/code/**
filters:
- StripPrefix=1
- id: service-user-router
uri: lb://dabing-service-user
predicates:
- Path=/api/user/**
filters:
- StripPrefix=1
httpclient:
connect-timeout: 5000
response-timeout: 20000
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
upstream myServer {
server 127.0.0.1:9090;
}
server {
listen 80;
server_name localhost;
location /static/ {
root staticDatas;
}
location / {
root html;
index index.html index.htm;
}
location /api {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://myServer;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
server:
port: 9090
eureka:
client:
serviceUrl:
defaultZone: http://dabingcloudeurekaservera:8761/eureka/,http://dabingcloudeurekaserverb:8762/eureka/ #把 eureka 集群中的所有 url 都填写了进来,也可以只写一台,因为各个 eureka server 可以同步注册表
instance:
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${spring.application.name}:${server.port}:@project.version@
spring:
application:
name: dabing-cloud-gateway
cloud:
gateway:
#开启网关的跨域功能,具体微服务上的跨域需要进行关闭,否则无效
globalcors:
cors-configurations:
'[/**]': # 匹配所有请求
allowedOrigins: "*" #跨域处理 允许所有的域
allowedMethods: # 支持的方法
- GET
- POST
- PUT
- DELETE
routes:
- id: service-code-router
uri: lb://dabing-service-code
predicates:
- Path=/api/code/**
filters:
- StripPrefix=1
- id: service-user-router
uri: lb://dabing-service-user
predicates:
- Path=/api/user/**
filters:
- StripPrefix=1
httpclient:
connect-timeout: 5000
response-timeout: 20000
demo仓库地址:https://gitee.com/lg_zk/dabing-user-parent-hw.git