登录功能
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<!-- Email & MIME Libraries -->
|
||||
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
||||
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||
<!-- Dependency Injection & Configuration -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
|
||||
6
Service/AppSettingModel/AuthSettings.cs
Normal file
6
Service/AppSettingModel/AuthSettings.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Service.AppSettingModel;
|
||||
|
||||
public class AuthSettings
|
||||
{
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
9
Service/AppSettingModel/JwtSettings.cs
Normal file
9
Service/AppSettingModel/JwtSettings.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Service.AppSettingModel;
|
||||
|
||||
public class JwtSettings
|
||||
{
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public int ExpirationHours { get; set; }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<van-config-provider :theme="theme">
|
||||
<RouterView />
|
||||
<van-tabbar v-model="active">
|
||||
<van-tabbar v-model="active" v-show="showTabbar">
|
||||
<van-tabbar-item icon="notes-o" to="/calendar">
|
||||
日历
|
||||
</van-tabbar-item>
|
||||
@@ -20,10 +20,15 @@
|
||||
|
||||
<script setup>
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 根据路由判断是否显示Tabbar
|
||||
const showTabbar = computed(() => {
|
||||
return route.path !== '/login'
|
||||
})
|
||||
|
||||
const active = ref(0)
|
||||
const theme = ref('light')
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { showToast } from 'vant'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
|
||||
// 创建 axios 实例
|
||||
const request = axios.create({
|
||||
@@ -13,11 +15,11 @@ const request = axios.create({
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
// 可以在这里添加 token 等认证信息
|
||||
// const token = localStorage.getItem('token')
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`
|
||||
// }
|
||||
// 添加 token 认证信息
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
@@ -53,6 +55,10 @@ request.interceptors.response.use(
|
||||
break
|
||||
case 401:
|
||||
message = '未授权,请重新登录'
|
||||
// 清除登录状态并跳转到登录页
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'transactions',
|
||||
component: () => import('../views/TransactionsRecord.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/email',
|
||||
name: 'email',
|
||||
component: () => import('../views/EmailRecord.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/setting',
|
||||
name: 'setting',
|
||||
component: () => import('../views/SettingView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../views/CalendarView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证
|
||||
|
||||
if (requiresAuth && !authStore.isAuthenticated) {
|
||||
// 需要认证但未登录,跳转到登录页
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (to.name === 'login' && authStore.isAuthenticated) {
|
||||
// 已登录用户访问登录页,跳转到首页
|
||||
next({ name: 'transactions' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
49
Web/src/stores/auth.js
Normal file
49
Web/src/stores/auth.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const expiresAt = ref(localStorage.getItem('expiresAt') || '')
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
if (!token.value || !expiresAt.value) return false
|
||||
// 检查token是否过期
|
||||
return new Date(expiresAt.value) > new Date()
|
||||
})
|
||||
|
||||
const login = async (password) => {
|
||||
try {
|
||||
const response = await request.post('/Auth/Login', { password })
|
||||
const { token: newToken, expiresAt: newExpiresAt } = response.data
|
||||
|
||||
token.value = newToken
|
||||
expiresAt.value = newExpiresAt
|
||||
|
||||
localStorage.setItem('token', newToken)
|
||||
localStorage.setItem('expiresAt', newExpiresAt)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
throw new Error('密码错误')
|
||||
}
|
||||
throw new Error('登录失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
expiresAt.value = ''
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('expiresAt')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
107
Web/src/views/LoginView.vue
Normal file
107
Web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1 class="login-title">账单</h1>
|
||||
<div class="login-form">
|
||||
<van-field
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
clearable
|
||||
:border="true"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #left-icon>
|
||||
<van-icon name="lock" />
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="login-button"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!password.value) {
|
||||
showToast('请输入密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(password.value)
|
||||
showToast({ type: 'success', message: '登录成功' })
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
showToast({ type: 'fail', message: error.message || '登录失败' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border-radius: 20px;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
margin-top: 10px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.van-field) {
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
:deep(.van-field__control) {
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -18,14 +18,25 @@
|
||||
<van-cell-group inset>
|
||||
<van-cell title="智能分类" is-link />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="detail-header">
|
||||
<p>账户</p>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="退出登录" is-link @click="handleLogout" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showLoadingToast, showSuccessToast, showToast, closeToast } from 'vant'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant'
|
||||
import { uploadBillFile } from '@/api/billImport'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const fileInputRef = ref(null)
|
||||
const currentType = ref('')
|
||||
|
||||
@@ -89,6 +100,25 @@ const handleFileChange = async (event) => {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退出登录
|
||||
*/
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '提示',
|
||||
message: '确定要退出登录吗?',
|
||||
})
|
||||
|
||||
authStore.logout()
|
||||
showSuccessToast('已退出登录')
|
||||
router.push({ name: 'login' })
|
||||
} catch (error) {
|
||||
console.error('取消退出登录:', error)
|
||||
showToast('已取消退出登录')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
86
WebApi/Controllers/AuthController.cs
Normal file
86
WebApi/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Service.AppSettingModel;
|
||||
|
||||
namespace WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/[action]")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly AuthSettings _authSettings;
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
IOptions<AuthSettings> authSettings,
|
||||
IOptions<JwtSettings> jwtSettings,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_authSettings = authSettings.Value;
|
||||
_jwtSettings = jwtSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用户登录
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost]
|
||||
public BaseResponse<LoginResponse> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
// 验证密码
|
||||
if (string.IsNullOrEmpty(request.Password) || request.Password != _authSettings.Password)
|
||||
{
|
||||
_logger.LogWarning("登录失败: 密码错误");
|
||||
return new BaseResponse<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "密码错误"
|
||||
};
|
||||
}
|
||||
|
||||
// 生成JWT Token
|
||||
var token = GenerateJwtToken();
|
||||
var expiresAt = DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours);
|
||||
|
||||
_logger.LogInformation("用户登录成功");
|
||||
|
||||
return new BaseResponse<LoginResponse>
|
||||
{
|
||||
Success = true,
|
||||
Data = new LoginResponse
|
||||
{
|
||||
Token = token,
|
||||
ExpiresAt = expiresAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateJwtToken()
|
||||
{
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
|
||||
new Claim("auth", "password-auth")
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _jwtSettings.Issuer,
|
||||
audience: _jwtSettings.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours),
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
6
WebApi/Controllers/Dto/LoginRequest.cs
Normal file
6
WebApi/Controllers/Dto/LoginRequest.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace WebApi.Controllers.Dto;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
7
WebApi/Controllers/Dto/LoginResponse.cs
Normal file
7
WebApi/Controllers/Dto/LoginResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace WebApi.Controllers.Dto;
|
||||
|
||||
public class LoginResponse
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Text;
|
||||
using FreeSql;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
using Service.AppSettingModel;
|
||||
@@ -35,6 +38,35 @@ builder.Services.AddCors(options =>
|
||||
// 绑定配置
|
||||
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||
builder.Services.Configure<AISettings>(builder.Configuration.GetSection("OpenAI"));
|
||||
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
|
||||
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
||||
|
||||
// 配置JWT认证
|
||||
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
|
||||
var secretKey = jwtSettings["SecretKey"]!;
|
||||
var key = Encoding.UTF8.GetBytes(secretKey);
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtSettings["Issuer"],
|
||||
ValidAudience = jwtSettings["Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// 配置 FreeSql + SQLite
|
||||
var dbPath = Path.Combine(AppContext.BaseDirectory, "database");
|
||||
@@ -81,6 +113,10 @@ app.UseStaticFiles();
|
||||
// 启用 CORS
|
||||
app.UseCors();
|
||||
|
||||
// 启用认证和授权
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// 添加 SPA 回退路由(用于前端路由)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="FreeSql.Provider.Sqlite" />
|
||||
|
||||
@@ -48,5 +48,14 @@
|
||||
"95555@message.cmbchina.com",
|
||||
"ccsvc@message.cmbchina.com"
|
||||
]
|
||||
},
|
||||
"JwtSettings": {
|
||||
"SecretKey": "6CA57F7D-B73F-AABC-007C-D2DF98E319DF-07802A80-1982-64CD-1CFE-466728053850",
|
||||
"Issuer": "EmailBillApi",
|
||||
"Audience": "EmailBillWeb",
|
||||
"ExpirationHours": 7200
|
||||
},
|
||||
"AuthSettings": {
|
||||
"Password": "SCsunch940622"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
networks:
|
||||
- all_in
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 14904:8080
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
@@ -16,20 +16,6 @@
|
||||
- /wd/apps/vols/emailbill/database:/app/database
|
||||
- /wd/apps/vols/emailbill/logs:/app/logs
|
||||
|
||||
nas_robot_proxy:
|
||||
image: beevelop/nginx-basic-auth:v2023.10.1
|
||||
container_name: emailbill_proxy
|
||||
restart: always
|
||||
networks:
|
||||
- all_in
|
||||
ports:
|
||||
- 14904:80 # 开放端口
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- HTPASSWD=suncheng:$$apr1$$2QX32QHP$$HIGAbCuTt8jxdc4uDzNLI1
|
||||
- FORWARD_PORT=8080
|
||||
- FORWARD_HOST=emailbill
|
||||
|
||||
networks:
|
||||
all_in:
|
||||
external: true
|
||||
Reference in New Issue
Block a user