单点登录(SSO)的核心目标是**"一次登录,多系统访问"**,需通过"统一认证中心"串联多个业务系统,实现身份凭证的跨系统共享与验证。以下基于 NestJS + Redis + JWT 实现完整的 SSO 流程,包含"统一认证中心(Auth Server)"和"业务系统(Client Server)"两端代码,覆盖"登录→凭证共享→跨系统验证→登出"全链路。
一、SSO 整体架构与核心逻辑
在开始代码实现前,需明确 SSO 的核心组件与数据流转:
- 统一认证中心(Auth Server):唯一的身份验证入口,负责用户登录、JWT 令牌生成、SSO 会话管理(Redis 存储);
- 业务系统(Client Server):如订单系统、会员系统等,自身不处理登录,依赖 Auth Server 验证用户身份;
- Redis:存储 SSO 全局会话(关联用户 ID 与所有登录系统)、JWT 黑名单(登出/失效令牌);
- JWT 令牌 :分为两种------
- SSO 令牌(SSO Token):由 Auth Server 生成,全局唯一,代表用户的 SSO 会话;
- 业务令牌(Client Token):业务系统接收 SSO Token 后,生成自身系统的 JWT(可选,减少跨系统请求 Auth Server 的频率)。
核心流程:
(key: SSO_TOKEN, value: 用户ID+系统列表)] C -->|5. 重定向回业务系统A,携带SSO Token| B B -->|6. 向Auth Server验证SSO Token| C C -->|7. 验证通过,返回用户信息| B B -->|8. 生成业务令牌(可选),允许访问| A A -->|9. 访问业务系统B| E[业务系统B] E -->|10. 未登录,重定向到Auth Server| C C -->|11. 检测到SSO会话已存在| E E -->|12. 向Auth Server验证SSO Token| C C -->|13. 验证通过,允许访问| A A -->|14. 登出(任意系统)| F[删除Redis中的SSO会话] F -->|15. 所有业务系统同步登出| A
二、前置准备:环境与依赖
1. 技术栈
- 后端框架:NestJS(Auth Server 与 Client Server 均基于此);
- 数据库:Redis(存储 SSO 会话、JWT 黑名单)、MySQL/PostgreSQL(存储用户基础信息);
- 核心依赖:
@nestjs/jwt
(JWT 生成/验证)、ioredis
(Redis 操作)、passport-jwt
(身份验证)、cookie-parser
(处理 Cookie,可选)。
2. 安装依赖(Auth Server 与 Client Server 均需安装)
shell
# 核心依赖
pnpm add @nestjs/jwt @nestjs/passport passport-jwt ioredis @nestjs-modules/ioredis bcryptjs
# 开发依赖
pnpm add -D @types/passport-jwt @types/ioredis @types/bcryptjs
三、统一认证中心(Auth Server)代码实现
Auth Server 是 SSO 的核心,需实现"用户登录""SSO Token 生成""跨系统令牌验证""SSO 会话管理(登出/失效)"四大功能。
1. 配置 Redis 与 JWT 模块
首先在 Auth Server 中配置 Redis(存储 SSO 会话)和 JWT(生成 SSO Token):
typescript
// src/app.module.ts(Auth Server)
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { RedisModule } from '@nestjs-modules/ioredis';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { User } from './users/entities/user.entity'; // 用户实体(id/username/password/role)
@Module({
imports: [
// 配置环境变量(存储JWT密钥、Redis地址、业务系统白名单等)
ConfigModule.forRoot({ isGlobal: true }),
// 配置Redis(存储SSO会话、JWT黑名单)
RedisModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
config: {
host: config.get('REDIS_HOST', 'localhost'),
port: config.get('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD', ''),
db: 0,
},
}),
}),
// 配置JWT(生成SSO Token)
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('SSO_JWT_SECRET', 'sso-prod-secret-2024'), // SSO专用密钥(需复杂)
signOptions: { expiresIn: '8h' }, // SSO Token有效期(8小时,可根据需求调整)
}),
}),
// 配置数据库(存储用户信息)
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get('DB_HOST'),
port: config.get('DB_PORT'),
username: config.get('DB_USERNAME'),
password: config.get('DB_PASSWORD'),
database: config.get('DB_NAME'),
entities: [User],
synchronize: false, // 生产环境关闭自动同步
}),
}),
AuthModule,
],
})
export class AppModule {}
2. 实现 SSO 核心服务(AuthService)
封装"用户登录验证""SSO Token 生成""SSO Token 验证""SSO 会话登出"等核心逻辑:
typescript
// src/auth/auth.service.ts(Auth Server)
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { Redis } from 'ioredis';
import { compareSync } from 'bcryptjs';
import { ConfigService } from '@nestjs/config';
import { User } from '../users/entities/user.entity';
// SSO会话在Redis中的key前缀(避免key冲突)
const SSO_SESSION_PREFIX = 'sso:session:';
// JWT黑名单在Redis中的key前缀(登出/失效的Token)
const JWT_BLACKLIST_PREFIX = 'sso:jwt:blacklist:';
@Injectable()
export class AuthService {
// 业务系统白名单(仅允许白名单内的系统访问SSO验证接口,防止恶意请求)
private readonly clientWhitelist: string[];
constructor(
@InjectRepository(User) private userRepo: Repository<User>,
private jwtService: JwtService,
@InjectRedis() private redis: Redis,
private configService: ConfigService,
) {
// 从环境变量读取业务系统白名单(如"https://order-system.com,https://member-system.com")
this.clientWhitelist = this.configService
.get('SSO_CLIENT_WHITELIST', '')
.split(',')
.filter(Boolean);
}
/**
* 1. 用户登录:验证账号密码,生成SSO Token与SSO会话
* @param username 用户名
* @param password 密码
* @param clientId 业务系统标识(如"order-system",用于记录哪个系统登录)
*/
async ssoLogin(username: string, password: string, clientId: string) {
// ① 验证业务系统是否在白名单内
if (!this.clientWhitelist.includes(clientId)) {
throw new ForbiddenException('非法业务系统,禁止访问SSO');
}
// ② 验证用户账号密码
const user = await this.userRepo
.createQueryBuilder('user')
.addSelect('user.password') // 显式查询加密后的密码
.where('user.username = :username', { username })
.getOne();
if (!user || !compareSync(password, user.password)) {
throw new UnauthorizedException('账号或密码错误');
}
// ③ 生成SSO Token(Payload包含用户ID、角色、业务系统ID)
const ssoToken = this.jwtService.sign({
sub: user.id, // 用户ID
username: user.username,
role: user.role,
clientId, // 当前登录的业务系统标识
});
// ④ 存储SSO会话到Redis(key: SSO_TOKEN, value: 包含用户ID+已登录系统列表的JSON)
const ssoSessionKey = `${SSO_SESSION_PREFIX}${ssoToken}`;
// 查询当前用户是否已有SSO会话(若有,追加当前业务系统)
const existingSession = await this.redis.get(ssoSessionKey);
const loginClients = existingSession
? [...JSON.parse(existingSession).loginClients, clientId]
: [clientId];
// 存储SSO会话(有效期与SSO Token一致,8小时)
await this.redis.set(
ssoSessionKey,
JSON.stringify({
userId: user.id,
loginClients: [...new Set(loginClients)], // 去重,避免重复添加同一系统
createdAt: Date.now(),
}),
'EX',
8 * 60 * 60, // 8小时(与JWT有效期同步)
);
// ⑤ 返回SSO Token与用户基础信息(不含敏感字段)
const { password: _, ...userInfo } = user;
return {
ssoToken,
userInfo,
expiresIn: 8 * 60 * 60, // 有效期(秒)
};
}
/**
* 2. 验证SSO Token:供业务系统调用,确认Token有效性
* @param ssoToken 业务系统传递的SSO Token
* @param clientId 业务系统标识(验证Token是否属于该系统)
*/
async validateSsoToken(ssoToken: string, clientId: string) {
// ① 验证业务系统白名单
if (!this.clientWhitelist.includes(clientId)) {
throw new ForbiddenException('非法业务系统,禁止验证SSO Token');
}
// ② 检查Token是否在黑名单(已登出/失效)
const isBlacklisted = await this.redis.get(`${JWT_BLACKLIST_PREFIX}${ssoToken}`);
if (isBlacklisted) {
throw new UnauthorizedException('SSO Token已失效,请重新登录');
}
// ③ 验证JWT签名与过期时间
let payload: any;
try {
payload = this.jwtService.verify(ssoToken);
} catch (error) {
throw new UnauthorizedException('SSO Token无效或已过期');
}
// ④ 验证Token中的业务系统标识与当前请求系统一致
if (payload.clientId !== clientId) {
throw new UnauthorizedException('SSO Token不属于当前业务系统');
}
// ⑤ 验证Redis中的SSO会话是否存在(防止Token未过期但会话被主动删除)
const ssoSessionKey = `${SSO_SESSION_PREFIX}${ssoToken}`;
const ssoSession = await this.redis.get(ssoSessionKey);
if (!ssoSession) {
throw new UnauthorizedException('SSO会话已失效,请重新登录');
}
// ⑥ 返回用户信息(供业务系统使用)
const { userId } = JSON.parse(ssoSession);
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('用户不存在');
}
const { password: _, ...userInfo } = user;
return { userInfo, ssoToken };
}
/**
* 3. SSO登出:删除Redis中的SSO会话与Token黑名单,实现全系统同步登出
* @param ssoToken 待登出的SSO Token
*/
async ssoLogout(ssoToken: string) {
// ① 查询SSO会话(确认会话存在)
const ssoSessionKey = `${SSO_SESSION_PREFIX}${ssoToken}`;
const ssoSession = await this.redis.get(ssoSessionKey);
if (!ssoSession) {
throw new UnauthorizedException('SSO会话不存在');
}
// ② 将Token加入黑名单(防止登出后继续使用)
const payload = this.jwtService.decode(ssoToken) as any;
await this.redis.set(
`${JWT_BLACKLIST_PREFIX}${ssoToken}`,
'invalid',
'EX',
payload.exp - Math.floor(Date.now() / 1000), // 黑名单有效期=Token剩余有效期
);
// ③ 删除SSO会话(全系统同步登出)
await this.redis.del(ssoSessionKey);
return { message: 'SSO登出成功,所有系统已同步登出' };
}
}
3. 实现 SSO 接口(AuthController)
提供"登录""Token验证""登出"三个对外接口,供前端和业务系统调用:
typescript
// src/auth/auth.controller.ts(Auth Server)
import { Controller, Post, Get, Query, Body, HttpCode } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('sso')
export class AuthController {
constructor(private authService: AuthService) {}
/**
* 接口1:用户登录(前端调用,如业务系统的登录页重定向到Auth Server的登录接口)
* @param body { username, password, clientId }
*/
@Post('login')
@HttpCode(200)
async ssoLogin(
@Body() body: { username: string; password: string; clientId: string },
) {
return this.authService.ssoLogin(body.username, body.password, body.clientId);
}
/**
* 接口2:验证SSO Token(业务系统后端调用,确认用户身份)
* @param ssoToken 业务系统传递的SSO Token
* @param clientId 业务系统标识
*/
@Get('validate-token')
async validateSsoToken(
@Query('ssoToken') ssoToken: string,
@Query('clientId') clientId: string,
) {
return this.authService.validateSsoToken(ssoToken, clientId);
}
/**
* 接口3:SSO登出(任意系统的前端调用,实现全系统同步登出)
* @param ssoToken 待登出的SSO Token
*/
@Post('logout')
@HttpCode(200)
async ssoLogout(@Body('ssoToken') ssoToken: string) {
return this.authService.ssoLogout(ssoToken);
}
}
四、业务系统(Client Server)代码实现
业务系统(如订单系统、会员系统)自身不处理用户登录,仅需实现"重定向到 Auth Server 登录""验证 SSO Token""生成业务令牌"三个核心逻辑,确保用户身份合法后允许访问。
1. 配置业务系统与 Auth Server 通信
首先在业务系统中配置 Auth Server 的地址、自身标识(clientId)等信息:
typescript
// src/app.module.ts(Client Server,以订单系统为例)
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { HttpModule } from '@nestjs/axios'; // 用于调用Auth Server的HTTP接口
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// 配置HTTP模块(调用Auth Server的接口)
HttpModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
baseURL: config.get('SSO_AUTH_SERVER_URL', 'https://sso-auth-server.com'), // Auth Server基础地址
timeout: 5000, // 超时时间
}),
}),
// 配置业务系统自身的JWT(可选,用于生成业务令牌,减少调用Auth Server的频率)
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('CLIENT_JWT_SECRET', 'order-system-secret'), // 业务系统专用密钥
signOptions: { expiresIn: '2h' }, // 业务令牌有效期(短于SSO Token)
}),
}),
UserModule,
AuthModule,
],
})
export class AppModule {}
2. 实现业务系统的 SSO 适配服务(ClientAuthService)
封装"重定向到 Auth Server 登录""验证 SSO Token""生成业务令牌"等逻辑:
typescript
// src/auth/client-auth.service.ts(Client Server)
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class ClientAuthService {
// 业务系统自身标识(需在Auth Server的白名单中)
private readonly clientId: string;
// 业务系统的回调地址(Auth Server登录成功后重定向回此地址)
private readonly redirectUri: string;
constructor(
private httpService: HttpService,
private jwtService: JwtService,
private configService: ConfigService,
) {
this.clientId = this.configService.get('SSO_CLIENT_ID', 'order-system'); // 如"order-system"
this.redirectUri = this.configService.get(
'SSO_REDIRECT_URI',
'https://order-system.com/sso/callback',
);
}
/**
* 1. 生成重定向到Auth Server的登录地址
* @param currentPath 当前访问的业务系统路径(登录成功后跳转回此路径)
*/
getSsoLoginUrl(currentPath: string) {
// 构造Auth Server的登录地址,携带业务系统标识和回调地址
const authServerLoginUrl = new URL(
'/sso/login',
this.configService.get('SSO_AUTH_SERVER_URL'),
);
authServerLoginUrl.searchParams.append('clientId', this.clientId);
authServerLoginUrl.searchParams.append('redirectUri', this.redirectUri);
authServerLoginUrl.searchParams.append('state', currentPath); // 记录当前路径,用于登录后跳转
return authServerLoginUrl.toString();
}
/**
* 2. 验证SSO Token(调用Auth Server的验证接口)
* @param ssoToken 从Auth Server获取的SSO Token
*/
async validateSsoToken(ssoToken: string) {
try {
// 调用Auth Server的验证接口
const response = await firstValueFrom(
this.httpService.get('/sso/validate-token', {
params: {
ssoToken,
clientId: this.clientId,
},
}),
);
return response.data; // { userInfo, ssoToken }
} catch (error) {
// 验证失败(如Token无效/过期),抛出未授权异常
throw new UnauthorizedException(
error.response?.data?.message || 'SSO身份验证失败,请重新登录',
);
}
}
/**
* 3. 生成业务系统自身的令牌(可选,减少重复调用Auth Server)
* @param userInfo 从Auth Server获取的用户信息
*/
generateClientToken(userInfo: any) {
return this.jwtService.sign({
sub: userInfo.id,
username: userInfo.username,
role: userInfo.role,
ssoToken: userInfo.ssoToken, // 携带SSO Token,用于后续刷新或登出
});
}
/**
* 4. 业务系统登出(调用Auth Server的登出接口,实现全系统同步登出)
* @param ssoToken SSO Token
*/
async logout(ssoToken: string) {
try {
await firstValueFrom(
this.httpService.post('/sso/logout', { ssoToken }),
);
return { message: '登出成功,已同步退出所有系统' };
} catch (error) {
throw new UnauthorizedException('登出失败,请重试');
}
}
}
3. 实现业务系统的 SSO 回调与接口保护
业务系统需提供"SSO 回调接口"(接收 Auth Server 重定向的 SSO Token)和"受保护接口"(验证用户身份):
typescript
// src/auth/client-auth.controller.ts(Client Server)
import { Controller, Get, Query, Res, UseGuards, Req, Post } from '@nestjs/common';
import { Response, Request } from 'express';
import { ClientAuthService } from './client-auth.service';
import { JwtAuthGuard } from './jwt-auth.guard'; // 业务系统自身的JWT守卫
@Controller('sso')
export class ClientAuthController {
constructor(private clientAuthService: ClientAuthService) {}
/**
* 接口1:SSO回调接口(Auth Server登录成功后重定向到这里)
* 接收SSO Token,生成业务令牌,存储到Cookie或返回给前端
*/
@Get('callback')
async ssoCallback(
@Query('ssoToken') ssoToken: string,
@Query('state') state: string, // 登录前的访问路径
@Res() res: Response,
) {
if (!ssoToken) {
return res.redirect('/login?error=缺少SSO令牌');
}
try {
// ① 验证SSO Token有效性
const { userInfo } = await this.clientAuthService.validateSsoToken(ssoToken);
// ② 生成业务系统自身的令牌(可选)
const clientToken = this.clientAuthService.generateClientToken({
...userInfo,
ssoToken,
});
// ③ 将业务令牌存储到httpOnly Cookie(推荐,防XSS)
res.cookie('client_token', clientToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // 生产环境启用HTTPS
maxAge: 2 * 60 * 60 * 1000, // 2小时(与业务令牌有效期一致)
path: '/',
});
// ④ 重定向回登录前的路径(如用户原本访问的订单详情页)
return res.redirect(state || '/');
} catch (error) {
return res.redirect(`/login?error=${error.message}`);
}
}
/**
* 接口2:业务系统登出接口
*/
@Post('logout')
@UseGuards(JwtAuthGuard) // 验证业务令牌
async logout(@Req() req: Request, @Res() res: Response) {
// 从请求中获取SSO Token(业务令牌的Payload中携带)
const ssoToken = req.user.ssoToken;
await this.clientAuthService.logout(ssoToken);
// 清除业务令牌Cookie
res.clearCookie('client_token');
return res.send({ message: '登出成功' });
}
}
4. 业务系统的接口保护(JWT守卫)
使用业务系统自身的 JWT 守卫保护接口,验证用户身份:
typescript
// src/auth/jwt-auth.guard.ts(Client Server)
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
// 从Cookie或请求头获取业务令牌
const token =
request.cookies.client_token ||
(request.headers.authorization?.split(' ')[1] || '');
if (!token) {
throw new UnauthorizedException('未登录,请先通过SSO登录');
}
try {
// 验证业务令牌
const payload = this.jwtService.verify(token);
// 将用户信息附加到req.user
request.user = payload;
} catch (error) {
throw new UnauthorizedException('登录已过期,请重新登录');
}
return true;
}
}
5. 业务系统的受保护接口示例
typescript
// src/user/user.controller.ts(Client Server,订单系统的用户接口)
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('user')
export class UserController {
// 需SSO登录才能访问的接口:获取当前用户的订单列表
@UseGuards(JwtAuthGuard)
@Get('orders')
getUserOrders(@Req() req) {
// req.user包含从SSO获取的用户信息
return {
userId: req.user.sub,
username: req.user.username,
orders: [], // 业务逻辑:查询该用户的订单
message: '订单列表查询成功',
};
}
}
五、SSO 前端适配逻辑(通用方案)
前端需实现"未登录时重定向到 Auth Server""接收 SSO Token 并存储""请求时携带业务令牌"三大逻辑,以下是简化示例(基于 Vue/React 通用逻辑):
javascript
// sso-utils.js(前端工具函数)
import { getToken, setToken, removeToken } from './storage'; // 封装localStorage/Cookie操作
// 1. 初始化:检查是否已登录,未登录则重定向到Auth Server
export function initSso() {
const currentPath = window.location.pathname;
const isLoggedIn = !!getToken('client_token');
if (!isLoggedIn && !currentPath.includes('/sso/callback')) {
// 未登录且不在回调页,重定向到Auth Server登录
const clientAuthService = new ClientAuthService(); // 封装的API服务
const loginUrl = clientAuthService.getSsoLoginUrl(currentPath);
window.location.href = loginUrl;
}
}
// 2. 处理SSO回调(在/sso/callback页面调用)
export async function handleSsoCallback() {
const urlParams = new URLSearchParams(window.location.search);
const ssoToken = urlParams.get('ssoToken');
const state = urlParams.get('state');
if (ssoToken) {
try {
// 调用业务系统的回调接口,验证Token并获取业务令牌
await api.post('/sso/callback', { ssoToken });
// 跳转回登录前的路径
window.location.href = state || '/';
} catch (error) {
// 处理错误(如Token无效)
window.location.href = `/login?error=${error.message}`;
}
}
}
// 3. 请求拦截器:为所有API请求附加业务令牌
export function setupRequestInterceptor(axiosInstance) {
axiosInstance.interceptors.request.use((config) => {
const token = getToken('client_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:处理401(登录过期)
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 登录过期,清除令牌并重定向到登录页
removeToken('client_token');
window.location.href = '/login';
}
return Promise.reject(error);
},
);
}
六、SSO 关键安全与扩展说明
1. 安全加固
- 白名单机制 :Auth Server 严格校验业务系统的
clientId
,仅允许白名单内的系统访问,防止恶意系统滥用 SSO; - Token 传输安全:SSO Token 和业务令牌必须通过 HTTPS 传输,避免中间人攻击;
- 令牌存储安全 :前端优先使用
httpOnly Cookie
存储令牌(防 XSS 攻击),配合SameSite=Strict
防 CSRF; - 会话有效期:SSO Token 有效期不宜过长(如 8 小时),业务令牌可更短(如 2 小时),降低令牌泄露风险。
2. 扩展场景
- 多端适配:移动端(App)可通过 WebView 集成 SSO,或使用 OAuth 2.0 扩展(如授权码模式);
- 刷新令牌机制:为 SSO Token 添加刷新令牌(存储在 Redis),避免用户频繁登录;
- 权限同步:当用户权限在 Auth Server 变更时,通过 Redis 发布订阅机制通知所有业务系统刷新权限缓存。
通过以上实现,多个业务系统可共享统一的用户身份,用户只需一次登录即可访问所有关联系统,大幅提升用户体验,同时通过 Redis 实现会话集中管理,确保登出时全系统同步失效,兼顾安全性与易用性。