Security

授权案例

本篇将结合 JWT 和 security 模块,实现一个简单的授权案例。

Security 组件包含一系列用户认证封装,熟练使用该组件可以提升认证模块的开发效率。由于该模块较为抽象,这个项目通过示例代码演示 Security 组件的用法,学习本示例你可以了解如下内容:

  • 从零创建 Malagu 项目
  • 使用 Security 组件实现用户登录认证
  • 使用 TypeORM 连接数据库(数据库使用 Mariadb)
  • Security 适配 JWT

创建项目

本篇介绍使用命令行创建一个 Malagu 项目,并通过 Mvc 组件来展示一个简单的页面。

terminal
mkdir security-demo # 创建项目目录
echo '{}' > package.json # 创建 package.json 文件
yarn add @malagu/core @malagu/mvc # 安装 Malagu 核心组件
yarn add --dev @malagu/cli @malagu/cli-service # 安装 CLI 工具

配置npm命令

编辑package.json添加如下内容:

package.json
{
  "name": "security-demo",
  "keywords": [
    "malagu-components"
  ],
  "scripts": {
    "start": "malagu serve"
  },
  // ...
}

完整 package.json 内容如下:

package.json
{
  "name": "security-demo",
  "keywords": [
    "malagu-components"
  ],
  "scripts": {
    "start": "malagu serve"
  },
  "dependencies": {
    "@malagu/core": "^2.56.0",
    "@malagu/mvc": "^2.56.0"
  },
  "devDependencies": {
    "@malagu/cli": "^2.56.0",
    "@malagu/cli-service": "^2.56.0"
  }
}

注意:必须在keywords中包含malagu-components,框架通过此配置来循环查找依赖链。

配置文件

创建tsconfig.json配置typescript编译参数,内容如下:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "module": "commonjs",
    "importHelpers": true,
    "strict": true,
    "jsx": "preserve",
    "moduleResolution": "node",
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "noImplicitAny": true,
    "noEmitOnError": false,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "strictNullChecks": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "downlevelIteration": true,
    "strictPropertyInitialization": false,
    "lib": [
      "es6",
      "dom"
    ],
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "lib",
    "baseUrl": "./src",
    "paths": {
      "~/*": ["*"],
    }
  },
  "include": [
    "src"
  ],
  "ts-node": {
    "transpileOnly": true
  }
}

撰写基础代码

接下来开始创建项目的代码文件。创建src/backend/controllers/home-controller.ts文件处理请求,内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Html } from "@malagu/mvc/lib/node";

@Controller("")
export class HomeController {
    @Get("/")
    @Html("home/index.mustache")
    indexAction() {
        return { name: "sam zhang" };
    }
}

可以看到我们使用了mustache作为模版引擎,因此要在对应的位置创建src/assets/views/home/index.mustache展示页面模板,文件内容如下:

src/assets/views/home/index.mustache
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>index</title>
</head>
<body>
    <p>home#index</p>
    <p>你好:{{ name }}</p>
</body>
</html>

最后创建src/backend/module.ts文件引入定义的controller并导出项目,内容如下:

src/backend/module.ts
import { autoBind } from "@malagu/core";
import "./controllers/home-controller";

export default autoBind();

启动项目:

terminal
yarn start

打开浏览器访问 http://localhost:3000 可以看到页面内容输出。这样,一个简单的 Malagu 项目就创建完成了。

添加认证模块

下面才是本篇的重点,我们将添加认证模块,实现用户登录认证。本篇演示使用 Security 模块实现用户登录和退出功能,并在登录错误展示错误信息。

首先安装@malagu/security模块:

terminal
yarn add @malagu/security

配置认证模块

添加认证逻辑,修改src/backend/controllers/home-controller.tsindexAction方法配置鉴权,修改后文件内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Html } from "@malagu/mvc/lib/node";
import { Authenticated } from "@malagu/security/lib/node";

@Controller("")
export class HomeController {
    @Get("/")
    @Html("home/index.mustache")
    @Authenticated()
    indexAction() {
        return { name: "sam zhang" };
    }
}

可以看到我们添加了一个@Authenticated装饰器,这个装饰器表示当前请求需要登录,否则会跳转到登录页面。运行项目并访问 http://localhost:3000 此时会跳转到登录页面 http://localhost:3000/login 。目前还没有登录页面,因此会显示 Not found ,我们来配置一下登录页即可:

创建src/assets/views/home/login.mustache文件展示登录表单,内容如下:

src/assets/views/home/login.mustache
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>login</title>
</head>
<body>
    <p>login</p>
    <form method="post">
        <div>
            <label for="">
                username:
                <input type="text" name="username" />
            </label>
        </div>
        <div>
            <label for="">
                password:
                <input type="password" name="password" />
            </label>
        </div>
        <div>
            <button type="submit">login</button>
        </div>
    </form>
</body>
</html>

修改src/backend/controllers/home-controller.ts添加loginAction方法展示登录逻辑,修改后文件内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Html } from "@malagu/mvc/lib/node";
import { Authenticated } from "@malagu/security/lib/node";

@Controller("")
export class HomeController {
    @Get("/")
    @Html("home/index.mustache")
    @Authenticated()
    indexAction() {
        return { name: "sam zhang" };
    }

    @Get("/login")
    @Html("home/login.mustache")
    loginAction() {
        return {};
    }
}

默认的登录页面地址,登录用户名和密码定义在Security组件malagu.yml中。我们来配置默认的登录密码为123456

修改malagu.yml配置默认密码,内容如下:

malagu.yml
malagu:
  security:
    password: ${ 'MzQ0NTg4ZTk2NzQyYWI1ODY0M2NjM2VjNWFkYjA0YzcwYWZiMzg3MTJhZjY5NGYw' | onTarget('backend')}
    logoutMethod: GET

完整的malagu.yml内容如下:

malagu.yml
backend:
  modules:
    - src/backend/module

malagu:
  security:
    password: ${ 'MzQ0NTg4ZTk2NzQyYWI1ODY0M2NjM2VjNWFkYjA0YzcwYWZiMzg3MTJhZjY5NGYw' | onTarget('backend')}
    logoutMethod: GET

运行项目并访问 http://localhost:3000 ,会跳转到登录页面。输入用户名admin,密码123456登录成功后跳转至首页。

展示登录用户信息

修改src/backend/controllers/home-controller.ts文件中的indexAction方法,修改后内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Html } from "@malagu/mvc/lib/node";
import { Authenticated, SecurityContext } from "@malagu/security/lib/node";

@Controller("")
export class HomeController {
    @Get("/")
    @Html("home/index.mustache")
    @Authenticated()
    indexAction() {
        const userInfo = SecurityContext.getAuthentication();
        return { name: userInfo.name };
    }

    @Get("/login")
    @Html("home/login.mustache")
    loginAction() {
        return {};
    }
}

我们导入了一个上下文:SecurityContext,调用SecurityContext.getAuthentication()方法可以获取当前登录的用户信息。

运行项目并访问 http://localhost:3000 会展示登录成功的用户名。

添加退出登录功能

修改src/assets/views/home/index.mustache添加退出登录按钮,完整文件内容如下:

src/assets/views/home/index.mustache
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>index</title>
</head>
<body>
    <p>home#index</p>
    <p>你好:{{ name }}</p>
    <a href="/logout">退出登录</a>
</body>
</html>

运行项目并访问 http://localhost:3000 点击退出登录,会退出当前登录的用户跳转到登录页。

添加错误处理

借助 Security 组件的能力我们实现了用户登录的功能,用户可以登录、退出。当输入错误的用户名或密码时,会重定向到登录页。为了给用户更好的体验,当登录失败时能够跳转到登录页面,并给用到户相应的提示。通过实现 Web 模块的 ErrorHandler 接口,我们可以实现自定义的错误处理逻辑。

创建src/backend/authentication/error-handler.ts文件处理鉴权错误,内容如下:

src/backend/authentication/error-handler.ts
import { Autowired, Component, Value } from "@malagu/core";
import { HttpHeaders, XML_HTTP_REQUEST, HttpStatus } from "@malagu/web";
import { Context, ErrorHandler, RedirectStrategy } from "@malagu/web/lib/node";
import { AUTHENTICATION_ERROR_HANDLER_PRIORITY, AuthenticationError, RequestCache } from "@malagu/security/lib/node";

@Component(ErrorHandler)
export class AuthenticationErrorHandler implements ErrorHandler {
    readonly priority: number = AUTHENTICATION_ERROR_HANDLER_PRIORITY + 100;

    @Value("malagu.security.basic.realm")
    protected realm: string;

    @Value("malagu.security.basic.enabled")
    protected readonly baseEnabled: boolean;

    @Value("malagu.security.loginPage")
    protected loginPage: string;

    @Autowired(RedirectStrategy)
    protected readonly redirectStrategy: RedirectStrategy;

    @Autowired(RequestCache)
    protected readonly requestCache: RequestCache;

    canHandle(ctx: Context, err: Error): Promise<boolean> {
        let isAuthError = err instanceof AuthenticationError;
        let isUnAutherized = err.message === "Unauthorized";
        return Promise.resolve(isAuthError && !isUnAutherized);
    }

    async handle(ctx: Context, err: AuthenticationError): Promise<void> {
        if (ctx.request.get(HttpHeaders.X_REQUESTED_WITH) !== XML_HTTP_REQUEST && !this.baseEnabled) {
            await this.requestCache.save();
            let username = ctx.request.body["username"];
            await this.redirectStrategy.send(this.loginPage+
                "?username="+encodeURIComponent(username)+
                "&err="+encodeURIComponent(err.message)
            );
            ctx.response.end(err.message);
        } else {
            if (this.baseEnabled) {
                ctx.response.setHeader(HttpHeaders.WWW_AUTHENTICATE, `Basic realm="${this.realm}"`);
            }
            ctx.response.statusCode = HttpStatus.UNAUTHORIZED;
            ctx.response.end(err.message);
        }
    }
}

点击查看默认的AuthenticationErrorHandler实现。

编辑src/backend/controllers/home-controller.ts文件修改loginAction方法将错误信息返回到模板文件,内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Query, Html } from "@malagu/mvc/lib/node";
import { Authenticated, SecurityContext } from "@malagu/security/lib/node";

@Controller("")
export class HomeController {
    @Get("/")
    @Html("home/index.mustache")
    @Authenticated()
    indexAction() {
        const userInfo = SecurityContext.getAuthentication();
        return { name: userInfo.name };
    }

    @Get("/login")
    @Html("home/login.mustache")
    loginAction(@Query('username') username: string = "", @Query("err") err: string = "") {
        console.log(err);
        return { username, err };
    }
}

修改src/assets/views/home/login.mustache文件展示错误信息,内容如下:

src/assets/views/home/login.mustache
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>login</title>
</head>
<body>
    <p>login</p>
    <form method="post">
        <div>
            <label for="">
                username:
                <input type="text" name="username" value="{{username}}" />
            </label>
        </div>
        <div>
            <label for="">
                password:
                <input type="password" name="password" />
            </label>
        </div>
        <div style="color: red;">{{ err }}</div>
        <div>
            <button type="submit">login</button>
        </div>
    </form>
</body>
</html>

最后一步,修改src/backend/module.ts文件,引入定义的error-handler.ts

src/backend/module.ts
import { autoBind } from "@malagu/core";
import "./controllers/home-controller";
import "./authentication/error-handler";

export default autoBind();

启动项目并访问,尝试输入错误的用户名或密码,会跳转到登录页面并显示相关错误信息。

连接数据库

安装typeorm框架对应的malagu模块:

terminal
yarn add crypto-js @malagu/typeorm
yarn add --dev @types/crypto-js

修改malagu.yml添加数据库配置,内容如下:

malagu.yml
backend: 
  malagu:
    typeorm:
      ormConfig:
        - type: mysql
          host: <db_host>
          port: 3306
          synchronize: true
          username: <db_user>
          password: "<db_pass>"
          database: security-demo
          logging: true

根据情况,将host、username、password等进行相应替换。

目前完整的malagu.yml内容如下:

malagu.yml
backend:
  modules:
    - src/backend/module
  malagu:
    typeorm:
      ormConfig:
        - type: mysql
          host: <db_host>
          port: 3306
          synchronize: true
          username: <db_user>
          password: "<db_pass>"
          database: security-demo
          logging: true

malagu:
  security:
    password: ${ 'MzQ0NTg4ZTk2NzQyYWI1ODY0M2NjM2VjNWFkYjA0YzcwYWZiMzg3MTJhZjY5NGYw' | onTarget('backend')}
    logoutMethod: GET

创建数据库

使用工具或命令行创建数据库,示例中数据库名为 security-demo,名称和上面malagu.yml中的datebase字段对应即可。typeorm会根据entity自动建表修改字段,不需要手动建表。

创建src/backend/entity/user.ts文件定义用户表实体,内容如下:

src/backend/entity/user.ts
import { BaseEntity, Entity, Column, PrimaryGeneratedColumn,
    CreateDateColumn, UpdateDateColumn, Unique } from "typeorm";

@Entity({ name: "users" })
@Unique(["username"])
export class User extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    username: string;

    @Column()
    password: string;

    @Column()
    desc: string;

    @CreateDateColumn({ name: "created_at" })
    createdAt: Date;

    @UpdateDateColumn({ name: "updated_at" })
    updatedAt: Date;
}

添加用户认证文件

创建src/backend/services/user-service.ts文件处理用户加载,内容如下:

src/backend/services/user-service.ts
import { Service } from "@malagu/core";
import { UserService } from "@malagu/security/lib/node";
import { User } from "../entity/user";

@Service({ id: UserService, rebind: true })
export class UserServiceImpl implements UserService<string, any> {
    async load(username: string): Promise<any> {
        let user = await User.findOne({ where: { username: username } });
        return user;
    }
}

点击查看默认的UserService实现。

创建src/backend/authentication/user-checker.ts文件处理用户检测,内容如下:

src/backend/authentication/user-checker.ts
import { Service } from "@malagu/core";
import { UserChecker, UsernameNotFoundError } from "@malagu/security/lib/node";

@Service({id: UserChecker, rebind: true})
export class UserCheckerImpl implements UserChecker {
    
    async check(user: any): Promise<void> {
        if (!user || !user.username) {
            throw new UsernameNotFoundError("User account not found");
        }
    }
}

点击查看默认的UserChecker实现。

创建src/backend/utils/crypto.ts文件处理密码加密,内容如下:

src/backend/utils/crypto.ts
import * as SHA256 from "crypto-js/sha256";

export function sha256Encode(content: string) {
    return SHA256(content).toString();
}

创建src/backend/authentication/password-encoder.ts文件处理密码比较,内容如下:

src/backend/authentication/password-encoder.ts
import { Service } from "@malagu/core";
import { PasswordEncoder } from "@malagu/security/lib/node";
import { sha256Encode } from "../utils/crypto";

@Service({ id: PasswordEncoder, rebind: true })
export class PasswordEncoderImpl implements PasswordEncoder {
    async encode(content: string): Promise<string> {
        return sha256Encode(content);
    }

    async matches(content: string, encoded: string): Promise<boolean> {
        let encodedContent = await this.encode(content);
        return encodedContent === encoded;
    }
}

点击查看默认的PasswordEncoder实现。

因为我们刚刚创建数据库,数据库中还没有用户,我们需要添加一个用户。修改src/backend/controllers/home-controller.ts添加createAction方法创建默认用户,内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Query, Html } from "@malagu/mvc/lib/node";
import { Authenticated, SecurityContext } from "@malagu/security/lib/node";
import { User } from "../entity/user";
import { sha256Encode } from "../utils/crypto";
import { Value } from "@malagu/core";

@Controller("")
export class HomeController {
    @Value("mode")
    mode: string;

    @Get("/")
    @Html("home/index.mustache")
    @Authenticated()
    indexAction() {
        const userInfo = SecurityContext.getAuthentication();
        return { name: userInfo.name };
    }

    @Get("/login")
    @Html("home/login.mustache")
    loginAction(@Query('username') username: string = "", @Query("err") err: string = "") {
        console.log(err);
        return { username, err };
    }

    @Get("/create")    
    async createAction() {
        if (this.mode && this.mode.indexOf('local') > -1) {
            let user: any = { username: "admin", password: "123456", desc: "默认用户"};
            user.password = sha256Encode(user.password);
            try {
                let saved = await User.save(user);
                let result = await User.findOne({ where: { username: user.username }}) as User;
                return result;
            }
            catch(err) {
                return { message: err.message };
            }
        }
        else {
            return { message: "not support" };
        }
    }
}

注意:正式环境请删除createAction方法。

修改src/module.ts引入上述文件,最终代码如下:

src/module.ts
import { autoBind } from "@malagu/core";
import { autoBindEntities } from "@malagu/typeorm";
import "./controllers/home-controller";
import "./authentication/error-handler";
import "./authentication/user-checker";
import "./authentication/password-encoder";
import "./services/user-service";
import * as entities from "./entity/user";

autoBindEntities(entities);
export default autoBind();

运行项目,访问 http://localhost:3000/create 此时刷新数据库会看到刚刚创建的 admin 用户。访问项目 http://localhost:3000/ 输入用户名、密码并点击登录,此时会跳转到首页并显示用户信息,说明用户认证成功。

添加JWT支持

安装@malagu/jwt模块:

terminal
yarn add @malagu/jwt

修改malagu.yml添加 jwt 密钥配置:

malagu.yml
malagu:
# 新增内容
jwt:
  secret: abcdefg

创建src/backend/authentication/authentication-success-handler.ts文件,登录成功时返回token,内容如下:

src/backend/authentication/authentication-success-handler.ts
import { Component, Autowired } from "@malagu/core";
import { Context } from "@malagu/web/lib/node";
import { AuthenticationSuccessHandler, Authentication } from "@malagu/security/lib/node";
import { JwtService } from "@malagu/jwt";

@Component({ id: AuthenticationSuccessHandler, rebind: true })
export class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {
    @Autowired(JwtService)
    jwtService: JwtService;

    async onAuthenticationSuccess(authentication: Authentication): Promise<void> {
        const response = Context.getResponse();
        let token = await this.jwtService.sign({ username: authentication.name });
        response.body = JSON.stringify({ token });
    }
}

点击查看默认的AuthenticationSuccessHandler实现。

创建src/backend/authentication/security-context-store.ts处理headerToken的请求,内容如下:

src/backend/authentication/security-context-store.ts
import { Autowired, Component, Value } from "@malagu/core";
import { User } from "@malagu/security";
import { SecurityContext, SecurityContextStore, SecurityContextStrategy, UserMapper, UserService } from "@malagu/security/lib/node";
import { Context } from '@malagu/web/lib/node';
import { JwtService } from "@malagu/jwt";

@Component({ id: SecurityContextStore, rebind: true })
export class SecurityContextStoreImpl implements SecurityContextStore {
    @Value('malagu.security')
    protected readonly options: any;

    @Autowired(UserService)
    protected readonly userService: UserService<string, User>;

    @Autowired(SecurityContextStrategy)
    protected readonly securityContextStrategy: SecurityContextStrategy;
    
    @Autowired(UserMapper)
    protected readonly userMapper: UserMapper;

    @Autowired(JwtService)
    jwtService: JwtService;

    async load(): Promise<any> {
        const request = Context.getRequest();
        const token = (request.get('Token') || '').trim()
        const securityContext = await this.securityContextStrategy.create();
        if (token) {
            const userInfo: any = await this.jwtService.verify(token);
            const user = await this.userService.load(userInfo.username);
            securityContext.authentication = {
                name: user.username,
                principal: this.userMapper.map(user),
                credentials: '',
                policies: user.policies,
                authenticated: true
            };
        }
        return securityContext;
    }

    async save(context: SecurityContext): Promise<void> {
    }
}

点击查看默认的SecurityContextStore实现。

修改src/backend/controllers/home-controller.ts文件,添加userAction方法返回用户信息,内容如下:

src/backend/controllers/home-controller.ts
import { Controller, Get, Query, Html } from "@malagu/mvc/lib/node";
import { Authenticated, SecurityContext } from "@malagu/security/lib/node";
import { User } from "../entity/user";
import { sha256Encode } from "../utils/crypto";
import { Value } from "@malagu/core";

@Controller("")
export class HomeController {
    @Value("mode")
    mode: string;

    @Get("/")
    @Html("home/index.mustache")
    @Authenticated()
    indexAction() {
        const userInfo = SecurityContext.getAuthentication();
        return { name: userInfo.name };
    }

    @Get("/user")
    @Authenticated()
    userAction() {
        const userInfo = SecurityContext.getAuthentication();
        return { name: userInfo.name };
    }

    @Get("/login")
    @Html("home/login.mustache")
    loginAction(@Query('username') username: string = "", @Query("err") err: string = "") {
        console.log(err);
        return { username, err };
    }

    @Get("/create")    
    async createAction() {
        if (this.mode && this.mode.indexOf('local') > -1) {
            let user: any = { username: "admin", password: "123456", desc: "默认用户"};
            user.password = sha256Encode(user.password);
            try {
                let saved = await User.save(user);
                let result = await User.findOne({ where: { username: user.username }}) as User;
                return result;
            }
            catch(err) {
                return { message: err.message };
            }
        }
        else {
            return { message: "not support" };
        }
    }
}

最后一步,修改src/backend/module.ts引入上述文件,最终代码如下:

src/backend/module.ts
import { autoBind } from "@malagu/core";
import { autoBindEntities } from "@malagu/typeorm";
import "./controllers/home-controller";
import "./authentication/error-handler";
import "./authentication/user-checker";
import "./authentication/password-encoder";
import "./services/user-service";
import * as entities from "./entity/user";
import "./authentication/authentication-success-handler";
import "./authentication/security-context-store";

autoBindEntities(entities);
export default autoBind();

运行并验证

启动项目,使用curl命令验证:

terminal
curl -H 'Content-Type: application/json' -X POST -d '{"username": "admin", "password": "123456"}' http://localhost:3000/login
# 此时返回 { "token": "xxxx" }
curl http://localhost:3000/user -H "Token: xxxx"
# 此时返回用户信息

Copyright © 2024 Zero (github@groupguanfang) 粤ICP备2023102563号