Feat: Add Passport + JWT

增加 Passport 和 JWT 验证支持(目前为假验证)前端登录页面,部分页面内容和样式优化
This commit is contained in:
奇趣保罗 2022-05-03 02:02:57 +08:00
parent b1a9e09396
commit 7823572503
20 changed files with 377 additions and 36 deletions

View File

@ -10,7 +10,9 @@
"dependencies": { "dependencies": {
"@midwayjs/hooks": "^3.0.0", "@midwayjs/hooks": "^3.0.0",
"@midwayjs/hooks-kit": "^3.0.0", "@midwayjs/hooks-kit": "^3.0.0",
"@midwayjs/jwt": "^3.3.5",
"@midwayjs/koa": "^3.3.0", "@midwayjs/koa": "^3.3.0",
"@midwayjs/passport": "^3.3.5",
"@midwayjs/redis": "^3.3.2", "@midwayjs/redis": "^3.3.2",
"@midwayjs/rpc": "^3.0.0", "@midwayjs/rpc": "^3.0.0",
"@prisma/client": "^3.12.0", "@prisma/client": "^3.12.0",
@ -18,6 +20,8 @@
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"isomorphic-unfetch": "^3.1.0", "isomorphic-unfetch": "^3.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport": "^0.5.2",
"passport-jwt": "^4.0.0",
"prismjs": "^1.27.0", "prismjs": "^1.27.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@ -27,6 +31,7 @@
"@midwayjs/mock": "^3.3.0", "@midwayjs/mock": "^3.3.0",
"@types/ioredis": "^4.28.10", "@types/ioredis": "^4.28.10",
"@types/lodash": "^4.14.181", "@types/lodash": "^4.14.181",
"@types/passport-jwt": "^3.0.6",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@types/react": "^17.0.44", "@types/react": "^17.0.44",
"@types/react-dom": "^17.0.15", "@types/react-dom": "^17.0.15",

View File

@ -26,15 +26,15 @@ export const Donate = () => {
<h3></h3> <h3></h3>
<p>~ API</p> <p>~ API</p>
<div className="row center"> <div className="row center">
<div className="col-6 col-m-4 center-fixed"> <div className="col-4 center-fixed">
<img src={Alipay} alt="支付宝" /> <img src={Alipay} alt="支付宝" />
<p></p> <p></p>
</div> </div>
<div className="col-6 col-m-4 center-fixed"> <div className="col-4 center-fixed">
<img src={WeChat} alt="微信支付" /> <img src={WeChat} alt="微信支付" />
<p></p> <p></p>
</div> </div>
<div className="col-6 col-m-4 center-fixed"> <div className="col-4 center-fixed">
<img src={QQ} alt="QQ 钱包" /> <img src={QQ} alt="QQ 钱包" />
<p>QQ </p> <p>QQ </p>
</div> </div>

View File

@ -0,0 +1,14 @@
import { createContext } from "react";
import { IGlobalData } from "@/types/GlobalData";
import { initalState, IAction } from "@/hooks/useGlobalData";
const GlobalContext = createContext<{ globalData: IGlobalData, setGlobalData: React.Dispatch<IAction> }>({
globalData: initalState,
setGlobalData() {}
});
GlobalContext.displayName = "GlobalContext";
export default GlobalContext;

View File

@ -1,25 +1,36 @@
// React // React
import React, { useState } from "react"; import React, { useState } from "react";
import useGlobalContext from "@/hooks/useGlobalContext";
// UI // UI
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Avatar from "../../images/avatar.jpg"; import Avatar from "@/images/avatar.jpg";
// Interface
import { MouseEvent } from "react";
// Components // Components
function Aside() { function Aside() {
const { globalData: { profile } } = useGlobalContext();
const [sideOpen, setSideOpen] = useState(false); const [sideOpen, setSideOpen] = useState(false);
const toggleClick = () => { const onMenuClick = (ev: MouseEvent<HTMLElement>) => {
let el = ev.target as HTMLElement;
el.tagName === "A" && setSideOpen(!sideOpen);
}
const onToggleClick = () => {
setSideOpen(!sideOpen); setSideOpen(!sideOpen);
} }
return ( return (
<aside className={sideOpen ? "sidebar active" : "sidebar"}> <aside className={sideOpen ? "sidebar active" : "sidebar"} onClick={onMenuClick}>
<div className="toggle" onClick={toggleClick}></div> <div className="toggle" onClick={onToggleClick}></div>
<div className="wrapper"> <div className="wrapper">
<div className="header"> <div className="header">
<h1>API</h1> <h1>API</h1>
@ -40,8 +51,10 @@ function Aside() {
</nav> </nav>
</nav> </nav>
<div className="user-area no-login"> <div className="user-area no-login">
<img src={Avatar} alt="头像" /> <Link to="/login">
<span className="username"></span> <img src={profile.avatar || Avatar} alt="头像" />
<span className="username">{profile.name || "未登录"}</span>
</Link>
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -1,9 +1,11 @@
// React // React
import React, { useEffect, useLayoutEffect } from "react"; import React, { useEffect, useLayoutEffect } from "react";
import useGlobalData from "@/hooks/useGlobalData";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
// Components // Components
import GlobalContext from "@/components/GlobalContext";
import Aside from "@/components/Layout/Aside"; import Aside from "@/components/Layout/Aside";
import Footer from "@/components/Layout/Footer"; import Footer from "@/components/Layout/Footer";
@ -18,6 +20,7 @@ interface FrontWrapperProps {
// Components // Components
function FrontWrapper(props: FrontWrapperProps) { function FrontWrapper(props: FrontWrapperProps) {
const location = useLocation(); const location = useLocation();
let [globalData, setGlobalData] = useGlobalData();
useEffect(() => { useEffect(() => {
const name = import.meta.env.PAUL_SITENAME; const name = import.meta.env.PAUL_SITENAME;
@ -35,11 +38,11 @@ function FrontWrapper(props: FrontWrapperProps) {
}, [location.pathname]); }, [location.pathname]);
return ( return (
<> <GlobalContext.Provider value={{ globalData, setGlobalData }}>
<Aside /> <Aside />
{props.element} {props.element}
<Footer /> <Footer />
</> </GlobalContext.Provider>
); );
} }

View File

@ -0,0 +1,9 @@
import { useContext } from "react";
import GlobalContext from "@/components/GlobalContext";
function useGlobalContext() {
return useContext(GlobalContext);
}
export default useGlobalContext;

View File

@ -0,0 +1,44 @@
import { useReducer } from "react";
import { IGlobalData } from "@/types/GlobalData";
export type IAction =
| { type: "all", value: IGlobalData }
| { type: "profile", value: Partial<IGlobalData["profile"]> }
export const initalState: IGlobalData = {
profile: {
name: "",
avatar: ""
}
};
function reducer(state: IGlobalData, action: IAction): IGlobalData {
if (!action) return state;
if (action.type === "all") {
return {
...state,
...action.value
};
}
else if (action.type === "profile") {
return {
...state,
profile: {
...state.profile,
...action.value
}
};
}
return state;
}
function useGlobalData(): [IGlobalData, React.Dispatch<IAction>]{
let [state, dispatch] = useReducer(reducer, initalState);
return [state, dispatch];
}
export default useGlobalData;

38
src/hooks/useStat.ts Normal file
View File

@ -0,0 +1,38 @@
import { useState } from "react";
export interface IParamsOption {
defaultValue?: Record<string, any>;
removeBlank?: boolean
}
function useStat<T extends Record<string, any>>(defaultValue: T, options?: IParamsOption | undefined): [T, ((newState: Partial<T>) => T), (value: React.SetStateAction<T>) => void];
// 适合组件状态设置的归档和存储,不可还原,需要还原成初始字段可使用 useParams
function useStat(initValue = {}, options?: IParamsOption | undefined) {
const [state, setState] = useState(initValue);
// 删除没用的
const removeUndefined = (obj: Record<string, any>) => {
const _keys = Object.keys(obj);
_keys.forEach(item => {
if (options?.removeBlank) {
(obj[item] === undefined || obj[item] === "") && delete obj[item];
}
else {
obj[item] === undefined && delete obj[item];
}
})
return obj;
}
// 覆盖部分值
const setStat = (newState: Record<string, any> = {}) => {
setState(prevState => removeUndefined({ ...prevState, ...newState }));
}
return [state, setStat, setState];
}
export default useStat;

View File

@ -2,7 +2,7 @@
# Paul API # Paul API
# By: Dreamer-Paul # By: Dreamer-Paul
# Last Update: 2021.12.16 # Last Update: 2022.5.3
保罗的首页作品和 API 页通用模板 保罗的首页作品和 API 页通用模板
@ -108,6 +108,7 @@ button{
color: #fff; color: #fff;
width: 10em; width: 10em;
position: fixed; position: fixed;
user-select: none;
background-color: #3498db; background-color: #3498db;
background-color: var(--primary); background-color: var(--primary);
transition: transform .3s; transition: transform .3s;
@ -146,6 +147,7 @@ button{
} }
.sidebar nav i{ .sidebar nav i{
opacity: .8; opacity: .8;
pointer-events: none;
transition: transform .3s; transition: transform .3s;
} }
@ -158,7 +160,7 @@ button{
} }
.sidebar a.active:before ,.sidebar .has-child > a:before{ .sidebar a.active:before ,.sidebar .has-child > a:before{
right: 0; right: 0;
content: ''; content: "";
position: absolute; position: absolute;
border: .75em solid transparent; border: .75em solid transparent;
border-right-color: #fff; border-right-color: #fff;
@ -177,22 +179,28 @@ button{
box-shadow: .25em 0 .5em rgba(0, 0, 0, .2); box-shadow: .25em 0 .5em rgba(0, 0, 0, .2);
} }
.sidebar a.active:before ,.sidebar .has-child > a:before{
content: none;
}
aside .toggle{ aside .toggle{
top: 0; top: 0;
left: 10em; left: 10em;
z-index: 3;
color: #fff;
width: 2.5em;
height: 2.5em;
cursor: pointer;
position: absolute; position: absolute;
z-index: 3;
width: 3em;
height: 3em;
color: #fff;
cursor: pointer;
line-height: 3em;
text-align: center; text-align: center;
background-color: var(--primary); background-color: var(--primary);
box-shadow: .25em 0 .5em rgba(0, 0, 0, .2); box-shadow: .25em 0 .5em rgba(0, 0, 0, .2);
} }
aside .toggle:before{ aside .toggle:before{
content: "\f0c9"; content: "\f0c9";
line-height: 2.5em; font-size: 1.2em;
font-family: "FontAwesome"; font-family: "FontAwesome";
} }
@ -207,20 +215,28 @@ button{
} }
.sidebar .user-area{ .sidebar .user-area{
cursor: pointer; transition: background-color .3s;
user-select: none; background-color: rgba(0, 0, 0, .1);
padding: .75em 1em;
transition: background .3s;
border-top: 1px solid rgba(0, 0, 0, .15);
} }
.sidebar .user-area:hover{ background: rgba(0, 0, 0, .2) } .sidebar .user-area:hover{ background-color: rgba(0, 0, 0, .2) }
.sidebar .user-area a{
color: inherit;
display: block;
padding: .75em 1em;
}
.sidebar .user-area img{ .sidebar .user-area img{
width: 2em; width: 2em;
height: 2em;
object-fit: cover;
border-radius: 66%; border-radius: 66%;
pointer-events: none;
border: 2px solid #fff; border: 2px solid #fff;
} }
.sidebar .user-area .username{ .sidebar .user-area .username{
margin-left: .5em; margin-left: .5em;
pointer-events: none;
vertical-align: middle; vertical-align: middle;
} }

View File

@ -10,6 +10,7 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
// Routes // Routes
import Home from "./pages/index"; import Home from "./pages/index";
import Login from "./pages/login";
import Notice from "./pages/notice"; import Notice from "./pages/notice";
import Log from "./pages/log"; import Log from "./pages/log";
import Netease from "./pages/netease"; import Netease from "./pages/netease";
@ -32,6 +33,9 @@ function App() {
<Route path="/" element={ <Route path="/" element={
<FrontWrapper element={<Home />} />} <FrontWrapper element={<Home />} />}
/> />
<Route path="/login" element={
<FrontWrapper title="登录" element={<Login />} /> }
/>
<Route path="/notice" element={ <Route path="/notice" element={
<FrontWrapper title="使用约定" element={<Notice />} /> } <FrontWrapper title="使用约定" element={<Notice />} /> }
/> />

View File

@ -18,7 +18,7 @@ const apiMap: Record<string, string> = {
function Index() { function Index() {
const { data: stat } = useRequest(() => getStat()); const { data: stat } = useRequest(getStat);
return ( return (
<main> <main>
@ -87,7 +87,6 @@ function Index() {
<About /> <About />
</article> </article>
</main> </main>
) )
} }

89
src/pages/login.tsx Normal file
View File

@ -0,0 +1,89 @@
// React
import React from "react";
import { useNavigate } from "react-router-dom";
import { useRequest } from "ahooks";
import useStat from "@/hooks/useStat";
import useGlobalContext from "@/hooks/useGlobalContext";
// UI
import ArticleHead from "@/components/Layout/ArticleHead";
// Tool
import loginRequest from "../server/api/auth/login";
// Interface
import { MouseEvent, ChangeEvent } from "react";
// Components
function Login() {
const navigate = useNavigate();
const { globalData, setGlobalData } = useGlobalContext();
const { loading, run } = useRequest(
(params) => loginRequest({
query: params
}),
{
manual: true,
onSuccess: (data) => {
console.log(data);
setGlobalData({
type: "profile",
value: data.profile
});
navigate("/")
}
}
);
const [input, setInput] = useStat({
username: "",
password: ""
});
const onSubmit = (ev: MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
run(input);
console.log("ok");
}
const onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
setInput({ [ev.target.name]: ev.target.value });
}
return (
<main>
<ArticleHead title="登录" desc="目前来说,登录是没有用的" />
<form>
<fieldset>
<label>
<input type="text" name="username" placeholder="用户名"
value={input.username} onChange={onInputChange}
/>
</label>
<label>
<input type="password" name="password" placeholder="密码"
value={input.password} onChange={onInputChange}
/>
</label>
<label>
<button className="btn green" onClick={onSubmit}>
{
loading ? <i className="fa fa-spinner fa-spin"></i> : "登录"
}
</button>
</label>
</fieldset>
</form>
</main>
)
}
export default Login;

View File

@ -1,8 +1,8 @@
import { Api, Get, Query, useContext, useInject } from "@midwayjs/hooks"; import { Api, Get, Query, useContext, useInject } from "@midwayjs/hooks";
import { RedisService } from "@midwayjs/redis"; import { RedisService } from "@midwayjs/redis";
import { prisma } from "../../utils/prisma"; import { prisma } from "../utils/prisma";
import { getSong } from "../../utils/netease"; import { getSong } from "../utils/netease";
export default Api( export default Api(
Get(), Get(),

View File

@ -1,11 +1,14 @@
import { Api, Post, Query, useContext, useInject } from "@midwayjs/hooks"; import { Api, Post, Query, useContext, useInject, Middleware } from "@midwayjs/hooks";
import { RedisService } from "@midwayjs/redis"; import { RedisService } from "@midwayjs/redis";
import { prisma } from "../../utils/prisma"; import { JwtPassportMiddleware } from "../../../middleware/jwt.middleware";
import { getSong } from "../../utils/netease"; import { prisma } from "../../../utils/prisma";
import { getSong } from "../../../utils/netease";
export default Api( export default Api(
Post(), Post(),
Middleware(JwtPassportMiddleware),
Query<{ id: string, bangumi: string }>(), Query<{ id: string, bangumi: string }>(),
async () => { async () => {
const ctx = useContext(); const ctx = useContext();

View File

@ -0,0 +1,27 @@
import { Api, Get, Query, useContext, useInject } from "@midwayjs/hooks";
import { JwtService } from '@midwayjs/jwt';
export default Api(
Get(),
Query<{ username: string, password: string }>(),
async () => {
const ctx = useContext();
const jwt = await useInject(JwtService);
const token = await jwt.sign({ name: "Paul" });
ctx.cookies.set("paul-token", token, {
maxAge: 60 * 60 * 24 * 2,
httpOnly: true
});
return {
profile: {
name: "Paul",
avatar: "https://sdn.geekzu.org/avatar/d22eb460ecab37fcd7205e6a3c55c228?s=200&r=X&d=",
},
token
}
}
);

View File

@ -2,6 +2,8 @@ import { createConfiguration, hooks } from "@midwayjs/hooks";
import * as Koa from "@midwayjs/koa"; import * as Koa from "@midwayjs/koa";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import * as redis from "@midwayjs/redis"; import * as redis from "@midwayjs/redis";
import * as jwt from "@midwayjs/jwt";
import * as passport from "@midwayjs/passport";
const env = dotenv.config(); const env = dotenv.config();
@ -9,7 +11,9 @@ const env = dotenv.config();
* setup midway server * setup midway server
*/ */
export default createConfiguration({ export default createConfiguration({
imports: [Koa, redis, hooks()], imports: [
Koa, redis, hooks(), jwt, passport
],
importConfigs: [{ importConfigs: [{
default: { default: {
keys: "session_keys", keys: "session_keys",
@ -20,6 +24,13 @@ export default createConfiguration({
db: 0, db: 0,
}, },
}, },
jwt: {
secret: "Test000666hhhh",
expiresIn: "5h",
},
passport: {
session: false
}
} }
}], }],
}); });

View File

@ -0,0 +1,30 @@
import { Middleware } from "@midwayjs/decorator";
import { PassportMiddleware } from "@midwayjs/passport";
import { JwtStrategy } from "../strategy/jwt.strategy";
import * as passport from "passport";
import { NextFunction, Context } from '@midwayjs/koa';
@Middleware()
export class JwtPassportMiddleware extends PassportMiddleware(JwtStrategy) {
getAuthenticateOptions(): Promise<passport.AuthenticateOptions> | passport.AuthenticateOptions {
return {};
}
// resolve() {
// return async (ctx: Context, next: NextFunction) => {
// const result = await next();
// console.log(result);
// return {
// code: 0,
// msg: '111',
// data: result,
// }
// };
// }
static getName(): string {
return "auth";
}
}

View File

@ -0,0 +1,30 @@
import { useContext } from "@midwayjs/hooks";
import { CustomStrategy, PassportStrategy } from '@midwayjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { Config } from '@midwayjs/decorator';
@CustomStrategy()
export class JwtStrategy extends PassportStrategy(
Strategy,
'jwt'
) {
@Config('jwt')
jwtConfig;
async validate(payload) {
console.log('payload', payload);
return payload;
}
getStrategyOptions(): any {
const ctx = useContext();
console.log(ctx.headers, ctx.cookies.get("paul-token"));
return {
secretOrKey: this.jwtConfig.secret,
jwtFromRequest: () => ctx.cookies.get("paul-token") //ExtractJwt.fromAuthHeaderAsBearerToken(),
};
}
}

6
src/types/GlobalData.ts Normal file
View File

@ -0,0 +1,6 @@
export interface IGlobalData {
profile: {
name: string
avatar: string
}
}