授权案例
本篇将结合 JWT 和 security 模块,实现一个简单的授权案例。
Security 组件包含一系列用户认证封装,熟练使用该组件可以提升认证模块的开发效率。由于该模块较为抽象,这个项目通过示例代码演示 Security 组件的用法,学习本示例你可以了解如下内容:
- 从零创建 Malagu 项目
- 使用 Security 组件实现用户登录认证
- 使用 TypeORM 连接数据库(数据库使用 Mariadb)
- Security 适配 JWT
创建项目
本篇介绍使用命令行创建一个 Malagu 项目,并通过 Mvc 组件来展示一个简单的页面。
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
添加如下内容:
{
"name": "security-demo",
"keywords": [
"malagu-components"
],
"scripts": {
"start": "malagu serve"
},
// ...
}
完整 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编译参数,内容如下:
{
"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
文件处理请求,内容如下:
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
展示页面模板,文件内容如下:
<!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
并导出项目,内容如下:
import { autoBind } from "@malagu/core";
import "./controllers/home-controller";
export default autoBind();
启动项目:
yarn start
打开浏览器访问 http://localhost:3000 可以看到页面内容输出。这样,一个简单的 Malagu 项目就创建完成了。
添加认证模块
下面才是本篇的重点,我们将添加认证模块,实现用户登录认证。本篇演示使用 Security 模块实现用户登录和退出功能,并在登录错误展示错误信息。
首先安装@malagu/security
模块:
yarn add @malagu/security
配置认证模块
添加认证逻辑,修改src/backend/controllers/home-controller.ts
给indexAction
方法配置鉴权,修改后文件内容如下:
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
文件展示登录表单,内容如下:
<!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
方法展示登录逻辑,修改后文件内容如下:
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:
security:
password: ${ 'MzQ0NTg4ZTk2NzQyYWI1ODY0M2NjM2VjNWFkYjA0YzcwYWZiMzg3MTJhZjY5NGYw' | onTarget('backend')}
logoutMethod: GET
完整的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
方法,修改后内容如下:
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
添加退出登录按钮,完整文件内容如下:
<!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
文件处理鉴权错误,内容如下:
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
方法将错误信息返回到模板文件,内容如下:
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
文件展示错误信息,内容如下:
<!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
:
import { autoBind } from "@malagu/core";
import "./controllers/home-controller";
import "./authentication/error-handler";
export default autoBind();
启动项目并访问,尝试输入错误的用户名或密码,会跳转到登录页面并显示相关错误信息。
连接数据库
安装typeorm
框架对应的malagu模块:
yarn add crypto-js @malagu/typeorm
yarn add --dev @types/crypto-js
修改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
内容如下:
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
文件定义用户表实体,内容如下:
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
文件处理用户加载,内容如下:
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
文件处理用户检测,内容如下:
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
文件处理密码加密,内容如下:
import * as SHA256 from "crypto-js/sha256";
export function sha256Encode(content: string) {
return SHA256(content).toString();
}
创建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
方法创建默认用户,内容如下:
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
引入上述文件,最终代码如下:
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
模块:
yarn add @malagu/jwt
修改malagu.yml
添加 jwt 密钥配置:
malagu:
# 新增内容
jwt:
secret: abcdefg
创建src/backend/authentication/authentication-success-handler.ts
文件,登录成功时返回token
,内容如下:
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
处理header
带Token
的请求,内容如下:
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
方法返回用户信息,内容如下:
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
引入上述文件,最终代码如下:
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
命令验证:
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"
# 此时返回用户信息