登录功能
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 30s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s

This commit is contained in:
孙诚
2025-12-25 13:27:23 +08:00
parent ebb49577dd
commit 728c39f43d
16 changed files with 395 additions and 23 deletions

View File

@@ -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" />

View File

@@ -0,0 +1,6 @@
namespace Service.AppSettingModel;
public class AuthSettings
{
public string Password { get; set; } = string.Empty;
}

View 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; }
}

View File

@@ -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')

View File

@@ -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 = '拒绝访问'

View File

@@ -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
View 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
View 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>

View File

@@ -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>

View 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);
}
}

View File

@@ -0,0 +1,6 @@
namespace WebApi.Controllers.Dto;
public class LoginRequest
{
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,7 @@
namespace WebApi.Controllers.Dto;
public class LoginResponse
{
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
}

View File

@@ -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 回退路由(用于前端路由)

View File

@@ -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" />

View File

@@ -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"
}
}

View File

@@ -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