最近开发的项目,基本都需要有登录和注册的需求。如果每个项目都抄一遍相同的代码,不仅麻烦,还难以更新。所以想写一个统一认证的登录服务。
在这里,选用 TypeScript+Fastify+TypeORM 来开发这个服务。因为访问量不高,所以暂时没有使用 Redis。
在数据库中,主要需要 4 张表,分别记录 用户信息、Ticket 信息、应用信息、验证码信息。因此设计 4 个 Entity:User、Ticket、Service 和 Captcha。
用户信息主要存储用户Id(唯一)、用户名、邮箱、手机号、密码(Hash之后)以及一些其他的信息。我们希望用户可以通过账户密码、邮箱验证、手机验证三种方式之一登录/注册,所以这些信息都允许为空,并且用户名和邮箱手机号均不能重复。
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ nullable: true })
username: string;
// Password is hashed
@Column({ nullable: true })
password: string;
@Index({ unique: true })
@Column({ nullable: true })
email: string;
@Index({ unique: true })
@Column({ nullable: true })
phone: string;
// Available
@Column({ default: true })
available: boolean;
// register time
@CreateDateColumn()
createDate: Date;
// last login time
@Column()
lastLoginDate: Date;
}
Service 主要记录平台中的应用服务信息,以及应用需要用到的用户信息。
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity()
export class Service {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column()
serviceId: string;
@Column()
serviceName: string;
@Column()
servicePath: string;
// 1 for username
// 2 for email
// 4 for phone
@Column({ default: 7 })
privilegeKeys: number;
}
Ticket 实体表示身份验证票据,当用户成功登录时会生成一个票据,用于后续的服务授权。票据分为 Service Ticket 和 Ticket Granting Ticket,用于实现跨服务的身份验证。
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
// CAS Ticket
@Entity()
export class Ticket {
@PrimaryGeneratedColumn()
id: number;
@Column()
ticket: string;
@Column()
userid: number;
@Column({ nullable: true })
serviceId: string;
@Column()
created: Date;
@Column({ nullable: true })
consumed: Date;
@Column()
expired: Date;
@Column({ nullable: true })
ticketGrantingTicket: string;
// ip
@Column()
ip: string;
// User-Agent
@Column()
ua: string;
}
这里的 Ticket 包含了票据的生成时间、到期时间以及是否被消费的标记。
Captcha 用于存储验证码以及其对应的验证状态,并且还加入了试错机制来保证安全性。
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Captcha {
@PrimaryGeneratedColumn()
id: number;
// The captcha string
@Column()
captcha: string;
// type 0 for phone, 1 for email
@Index()
@Column()
type: number;
// The phone number or email address
@Index()
@Column()
target: string;
// expired time
@Column()
expired: Date;
// created time
@CreateDateColumn()
createDate: Date;
// comsumed
@Column({ default: false })
consumed: boolean;
// Try times
@Column({ default: 0 })
tryTimes: number;
// ip
@Column()
ip: string;
// ua
@Column()
ua: string;
}
就只要从数据库中找对应的验证码即可。并且规定每个验证码最多尝试 3 次。
async function checkCaptcha(target: string, type: number, captcha: string) {
const captchaRepository = app.dataSource.getRepository(Captcha);
const captchaData = await captchaRepository.findOne({
where: {
target,
type,
consumed: false,
expired: MoreThan(new Date(Date.now())),
},
});
if (!captchaData) {
return false;
}
if (captchaData.captcha !== captcha) {
captchaData.tryTimes += 1;
await captchaRepository.save(captchaData);
if (captchaData.tryTimes >= 3) {
captchaData.consumed = true;
await captchaRepository.save(captchaData);
}
return false;
}
captchaData.consumed = true;
await captchaRepository.save(captchaData);
return true;
}
登录首先检查用户是否已经有 TGT 了(已经登录),如果有 TGT,直接创建 ST 并返回即可。否则进行登录流程,用户可以选择用户名密码/邮箱验证/手机验证三种方式之一进行登录请求。登录成功之后,创建 TGT 和 ST 并返回给应用。
if (req.session.tgt) {
// 使用 TGT 验证用户身份
let tgt = await ticketRepository.findOne({
where: {
userid: user.id,
ticketGrantingTicket: null,
expired: MoreThan(new Date(Date.now())),
},
});
if (tgt) {
const st = ticketRepository.create({
ticket: crypto.randomBytes(32).toString("hex"),
userid: user.id,
serviceId,
consumed: null,
created: new Date(),
expired: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1天有效期
ticketGrantingTicket: tgt.ticket, // 关联 TGT
});
await ticketRepository.save(st);
return { success: true, tgt: tgt.ticket, ticket: st.ticket };
}
}
// 如果提交了用户名,就按照用户名和密码的方式验证
if (username) {
// 提交了用户名就必须提交密码
if (!password) {
return reply.status(400).send({
success: false,
message: "Missing password",
});
}
// 从数据库中查找对应的用户,然后验证密码
user = await userRepository.findOne({ where: { username } });
if (!user) {
return reply.status(401).send({
success: false,
message: "Invalid username or password.",
});
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return reply.status(401).send({
success: false,
message: "Invalid username or password.",
});
}
} else if (email) {
// 验证邮箱和验证码
if (!emailCaptcha) {
return reply.status(400).send({
success: false,
message: "Missing email captcha",
});
}
user = await userRepository.findOne({ where: { email } });
if (!user) {
return reply.status(404).send({
success: false,
message: "User not found",
});
}
if (!(await checkCaptcha(email, 1, emailCaptcha))) {
return reply.status(400).send({
success: false,
message: "Invalid email captcha",
});
}
} else if (phone) {
// 验证手机号和验证码
if (!phoneCaptcha) {
return reply.status(400).send({
success: false,
message: "Missing phone captcha",
});
}
user = await userRepository.findOne({ where: { phone } });
if (!user) {
return reply.status(404).send({
success: false,
message: "User not found",
});
}
if (!(await checkCaptcha(phone, 0, phoneCaptcha))) {
return reply.status(400).send({
success: false,
message: "Invalid phone captcha",
});
}
}
首先搜索是否已有有效的 TGT,否则就生成一个新的。之后根据 TGT 生成一个 ST,然后来发送给客户端。
const ticketRepository = app.dataSource.getRepository(Ticket);
let tgt = await ticketRepository.findOne({
where: {
userid: user.id,
ticketGrantingTicket: null,
expired: MoreThan(new Date(Date.now())),
},
});
// 如果已经存在有效的TGT,直接返回,否则创建新的
if (!tgt) {
tgt = ticketRepository.create({
ticket: crypto.randomBytes(32).toString("hex"),
userid: user.id,
consumed: null,
created: new Date(),
expired: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天有效期
ticketGrantingTicket: null, // 标记为 TGT
});
await ticketRepository.save(tgt);
}
// 生成新的 ST
const st = ticketRepository.create({
ticket: crypto.randomBytes(32).toString("hex"),
userid: user.id,
serviceId,
consumed: null,
created: new Date(),
expired: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1天有效期
ticketGrantingTicket: tgt.ticket, // 关联 TGT
});
await ticketRepository.save(st);
return { success: true, tgt: tgt.ticket, ticket: st.ticket };
注册其实和登录类似,只不过注册的时候先查询是否有重复的用户名/邮箱/手机号,其余逻辑类似。注册成功之后就直接创建TGT和ST即可。
应用发送有效的 ST,那么就可以返回对应的用户信息。如果应用没有有效的 ST,但是有 TGT,那么就可以用 TGT 来生成一个 ST。
const { ticket, tgt, serviceId } = request.body;
const serviceRepository = app.dataSource.getRepository(Service);
const service = await serviceRepository.findOne({
where: { serviceId },
});
if (!service) {
return reply.status(404).send({
success: false,
message: "Service not found",
});
}
const ticketRepository = app.dataSource.getRepository(Ticket);
// 检查 Service Ticket (ST) 是否有效
let serviceTicket = await ticketRepository.findOne({
where: {
ticket,
consumed: null,
expired: MoreThan(new Date()), // 确保票据未过期
},
});
if (!serviceTicket) {
// 如果 ST 无效,检查是否提供了有效的 TGT
if (tgt) {
const tgtTicket = await ticketRepository.findOne({
where: {
ticket: tgt,
ticketGrantingTicket: null, // 确保这是 TGT
expired: MoreThan(new Date()), // 确保 TGT 未过期
},
});
if (!tgtTicket) {
return reply.status(401).send({
success: false,
message: "Invalid TGT",
});
}
// 生成新的 Service Ticket (ST)
serviceTicket = ticketRepository.create({
ticket: crypto.randomBytes(32).toString("hex"),
userid: tgtTicket.userid,
serviceId,
consumed: null,
created: new Date(),
expired: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1天有效期
ticketGrantingTicket: tgt, // 关联 TGT
});
await ticketRepository.save(serviceTicket);
return {
success: true,
message: "New Service Ticket generated",
ticket: serviceTicket.ticket,
};
}
return reply.status(401).send({
success: false,
message: "Invalid or expired Service Ticket",
});
}
// 标记 ST 为已消费
serviceTicket.consumed = new Date();
await ticketRepository.save(serviceTicket);
return {
success: true,
message: "Service Ticket valid",
userId: serviceTicket.userid,
};
本篇基本讲解了后端开发,之后会另写两篇文章分别讲解前端实现和其他应用的接入。