yemaster的小窝

类CAS服务开发——后端实现

2024-09-21
109

前言

最近开发的项目,基本都需要有登录和注册的需求。如果每个项目都抄一遍相同的代码,不仅麻烦,还难以更新。所以想写一个统一认证的登录服务。

在这里,选用 TypeScript+Fastify+TypeORM 来开发这个服务。因为访问量不高,所以暂时没有使用 Redis。

技术栈

  • Fastify: 一个高性能的 Nodejs 后端框架
  • TypeORM: 一个支持 TypeScript 的 ORM 框架

主要功能

  • 用户登录与注册
  • 验证码的发送与验证,包括手机验证码和邮箱验证码
  • 基于Tickets的身份验证机制

Entity设计

在数据库中,主要需要 4 张表,分别记录 用户信息、Ticket 信息、应用信息、验证码信息。因此设计 4 个 Entity:User、Ticket、Service 和 Captcha。

User Entity

用户信息主要存储用户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 Entity

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 Entity

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 Entity

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 并返回给应用。

是否已经有 TGT 验证

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,
};

前端实现与应用接入

本篇基本讲解了后端开发,之后会另写两篇文章分别讲解前端实现和其他应用的接入。

链接

分类标签:fastify CAS nodejs TypeORM
Comments

目录