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

View File

@ -26,15 +26,15 @@ export const Donate = () => {
<h3></h3>
<p>~ API</p>
<div className="row center">
<div className="col-6 col-m-4 center-fixed">
<div className="col-4 center-fixed">
<img src={Alipay} alt="支付宝" />
<p></p>
</div>
<div className="col-6 col-m-4 center-fixed">
<div className="col-4 center-fixed">
<img src={WeChat} alt="微信支付" />
<p></p>
</div>
<div className="col-6 col-m-4 center-fixed">
<div className="col-4 center-fixed">
<img src={QQ} alt="QQ 钱包" />
<p>QQ </p>
</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
import React, { useState } from "react";
import useGlobalContext from "@/hooks/useGlobalContext";
// UI
import { Link } from "react-router-dom";
import Avatar from "../../images/avatar.jpg";
import Avatar from "@/images/avatar.jpg";
// Interface
import { MouseEvent } from "react";
// Components
function Aside() {
const { globalData: { profile } } = useGlobalContext();
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);
}
return (
<aside className={sideOpen ? "sidebar active" : "sidebar"}>
<div className="toggle" onClick={toggleClick}></div>
<aside className={sideOpen ? "sidebar active" : "sidebar"} onClick={onMenuClick}>
<div className="toggle" onClick={onToggleClick}></div>
<div className="wrapper">
<div className="header">
<h1>API</h1>
@ -40,8 +51,10 @@ function Aside() {
</nav>
</nav>
<div className="user-area no-login">
<img src={Avatar} alt="头像" />
<span className="username"></span>
<Link to="/login">
<img src={profile.avatar || Avatar} alt="头像" />
<span className="username">{profile.name || "未登录"}</span>
</Link>
</div>
</div>
</aside>

View File

@ -1,9 +1,11 @@
// React
import React, { useEffect, useLayoutEffect } from "react";
import useGlobalData from "@/hooks/useGlobalData";
import { useLocation } from "react-router-dom";
// Components
import GlobalContext from "@/components/GlobalContext";
import Aside from "@/components/Layout/Aside";
import Footer from "@/components/Layout/Footer";
@ -18,6 +20,7 @@ interface FrontWrapperProps {
// Components
function FrontWrapper(props: FrontWrapperProps) {
const location = useLocation();
let [globalData, setGlobalData] = useGlobalData();
useEffect(() => {
const name = import.meta.env.PAUL_SITENAME;
@ -35,11 +38,11 @@ function FrontWrapper(props: FrontWrapperProps) {
}, [location.pathname]);
return (
<>
<GlobalContext.Provider value={{ globalData, setGlobalData }}>
<Aside />
{props.element}
<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
# By: Dreamer-Paul
# Last Update: 2021.12.16
# Last Update: 2022.5.3
保罗的首页作品和 API 页通用模板
@ -108,6 +108,7 @@ button{
color: #fff;
width: 10em;
position: fixed;
user-select: none;
background-color: #3498db;
background-color: var(--primary);
transition: transform .3s;
@ -146,6 +147,7 @@ button{
}
.sidebar nav i{
opacity: .8;
pointer-events: none;
transition: transform .3s;
}
@ -158,7 +160,7 @@ button{
}
.sidebar a.active:before ,.sidebar .has-child > a:before{
right: 0;
content: '';
content: "";
position: absolute;
border: .75em solid transparent;
border-right-color: #fff;
@ -177,22 +179,28 @@ button{
box-shadow: .25em 0 .5em rgba(0, 0, 0, .2);
}
.sidebar a.active:before ,.sidebar .has-child > a:before{
content: none;
}
aside .toggle{
top: 0;
left: 10em;
z-index: 3;
color: #fff;
width: 2.5em;
height: 2.5em;
cursor: pointer;
position: absolute;
z-index: 3;
width: 3em;
height: 3em;
color: #fff;
cursor: pointer;
line-height: 3em;
text-align: center;
background-color: var(--primary);
box-shadow: .25em 0 .5em rgba(0, 0, 0, .2);
}
aside .toggle:before{
content: "\f0c9";
line-height: 2.5em;
font-size: 1.2em;
font-family: "FontAwesome";
}
@ -207,20 +215,28 @@ button{
}
.sidebar .user-area{
cursor: pointer;
user-select: none;
padding: .75em 1em;
transition: background .3s;
border-top: 1px solid rgba(0, 0, 0, .15);
transition: background-color .3s;
background-color: rgba(0, 0, 0, .1);
}
.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{
width: 2em;
height: 2em;
object-fit: cover;
border-radius: 66%;
pointer-events: none;
border: 2px solid #fff;
}
.sidebar .user-area .username{
margin-left: .5em;
pointer-events: none;
vertical-align: middle;
}

View File

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

View File

@ -18,7 +18,7 @@ const apiMap: Record<string, string> = {
function Index() {
const { data: stat } = useRequest(() => getStat());
const { data: stat } = useRequest(getStat);
return (
<main>
@ -87,7 +87,6 @@ function Index() {
<About />
</article>
</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 { 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(
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 { 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(
Post(),
Middleware(JwtPassportMiddleware),
Query<{ id: string, bangumi: string }>(),
async () => {
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 dotenv from "dotenv";
import * as redis from "@midwayjs/redis";
import * as jwt from "@midwayjs/jwt";
import * as passport from "@midwayjs/passport";
const env = dotenv.config();
@ -9,7 +11,9 @@ const env = dotenv.config();
* setup midway server
*/
export default createConfiguration({
imports: [Koa, redis, hooks()],
imports: [
Koa, redis, hooks(), jwt, passport
],
importConfigs: [{
default: {
keys: "session_keys",
@ -20,6 +24,13 @@ export default createConfiguration({
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
}
}