//! 认证路由 — 登录/登出/当前用户信息 //! //! - GET /api/auth/challenge — 获取登录 challenge(防暴力破解) //! - POST /api/auth/login — 手机号+密码登录,返回 JWT token //! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态) //! - GET /api/auth/me — 获取当前登录用户信息及权限码 //! - POST /api/auth/wx-login — 微信小程序手机号快速登录 //! - PUT /api/auth/avatar — 更新用户头像 URL use axum::extract::{Multipart, State}; use axum::http::HeaderMap; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use std::io::Write; use uuid::Uuid; const AVATAR_DIR: &str = "uploads/avatars"; use reqwest::Client; use super::upload_guard; use crate::captcha; use crate::commands::AppState; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; /// 登录失败计数阈值:同一手机号 / 同一 IP 在窗口期内最多允许的失败次数 const LOGIN_FAIL_MAX: i64 = 5; /// 登录失败计数窗口(秒)—— Redis TTL,到期自动清零 const LOGIN_FAIL_WINDOW_SECS: u64 = 900; /// challenge 发码限流:同一 IP 每分钟最多请求次数(免认证接口,防 CPU 打满) const CHALLENGE_RATE_MAX: i64 = 10; /// challenge 发码限流窗口(秒) const CHALLENGE_RATE_WINDOW_SECS: u64 = 60; /// 登录失败统一文案(锁定期同样返回它,不泄露"账号已锁"等可枚举信息) const LOGIN_FAIL_MESSAGE: &str = "手机号或密码错误"; /// 客户端 IP 请求头 —— 只信任 nginx `proxy_set_header X-Real-IP $remote_addr` 注入的值, /// 不读取客户端可任意伪造的 `X-Forwarded-For` 链 const CLIENT_IP_HEADER: &str = "x-real-ip"; /// 取不到 IP 时的兜底标识(无头直连请求共用一个计数桶) const UNKNOWN_CLIENT_IP: &str = "unknown"; /// 从请求头取客户端 IP(仅信任 X-Real-IP,取不到时用固定兜底值) fn client_ip(headers: &HeaderMap) -> String { headers .get(CLIENT_IP_HEADER) .and_then(|v| v.to_str().ok()) .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(UNKNOWN_CLIENT_IP) .to_string() } /// Redis 自增计数:键首次创建时设置 TTL(固定窗口,不因后续自增而延长) async fn incr_with_ttl(state: &AppState, key: &str, ttl_secs: u64) -> Result { let mut conn = state.redis.clone(); let count: i64 = redis::cmd("INCR").arg(key).query_async(&mut conn).await?; if count == 1 { let _: () = redis::cmd("EXPIRE") .arg(key) .arg(ttl_secs) .query_async(&mut conn) .await?; } Ok(count) } /// 读取计数(键不存在按 0);Redis 异常时按 0 处理(fail-open,仅记 ERROR 日志) /// /// fail-open 的理由:登录链路本身已依赖 Redis(验证码校验失败即拒绝), /// 这里不重复阻断,避免缓存抖动导致全体用户无法登录。 async fn read_counter(state: &AppState, key: &str) -> i64 { let mut conn = state.redis.clone(); match redis::cmd("GET") .arg(key) .query_async::>(&mut conn) .await { Ok(Some(v)) => v.parse::().unwrap_or(0), Ok(None) => 0, Err(e) => { tracing::error!(err = %e, key = %key, "读取登录失败计数失败,按 0 处理"); 0 } } } /// 清除计数(登录成功后调用);失败仅记 ERROR 日志,不影响登录结果 async fn clear_counter(state: &AppState, key: &str) { let mut conn = state.redis.clone(); if let Err(e) = redis::cmd("DEL").arg(key).query_async::<()>(&mut conn).await { tracing::error!(err = %e, key = %key, "清除登录失败计数失败"); } } /// 记录一次登录失败:手机号 + IP 双维度各自自增,达到阈值记 WARN /// /// 返回两个维度的较大计数(供调用方日志使用)。 async fn record_login_failure( state: &AppState, phone_key: &str, ip_key: &str, phone: &str, ip: &str, ) -> i64 { let phone_fails = match incr_with_ttl(state, phone_key, LOGIN_FAIL_WINDOW_SECS).await { Ok(n) => n, Err(e) => { tracing::error!(err = %e, key = %phone_key, "手机号维度失败计数写入失败"); 0 } }; let ip_fails = match incr_with_ttl(state, ip_key, LOGIN_FAIL_WINDOW_SECS).await { Ok(n) => n, Err(e) => { tracing::error!(err = %e, key = %ip_key, "IP 维度失败计数写入失败"); 0 } }; if phone_fails >= LOGIN_FAIL_MAX || ip_fails >= LOGIN_FAIL_MAX { tracing::warn!( phone = %phone, ip = %ip, phone_fails, ip_fails, window_secs = LOGIN_FAIL_WINDOW_SECS, "登录失败次数达到阈值,该维度已锁定" ); } phone_fails.max(ip_fails) } /// 登录请求体 #[derive(Debug, Deserialize)] pub(crate) struct LoginInput { phone: String, password: String, /// 登录 challenge token,从 GET /api/auth/challenge 获取 #[serde(default)] challenge_id: String, /// 滑块验证码偏移量(前端拖拽拼图后获得) #[serde(default)] captcha_x: Option, } /// GET /api/auth/challenge — 获取登录滑块验证码 /// /// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。 /// 该接口免认证,故按客户端 IP 限流(10 次/分钟),并配合 `captcha` 内部的渲染并发闸门, /// 避免被用来打满 CPU(第二道限流由 nginx `limit_req` 承担)。 pub async fn get_challenge( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let ip = client_ip(&headers); let rate_key = format!("auth:challenge:rate:{}", ip); let count = incr_with_ttl(&state, &rate_key, CHALLENGE_RATE_WINDOW_SECS).await?; if count > CHALLENGE_RATE_MAX { tracing::warn!(ip = %ip, count, max = CHALLENGE_RATE_MAX, "challenge 请求超过限流阈值,已拒绝"); return Err(AppError::TooManyRequests("请求过于频繁,请稍后再试".into())); } let data = captcha::create_challenge(&state.redis_url).await?; tracing::debug!("[challenge] 生成 cid={}", data.challenge_id); Ok(Json(json!({ "code": 0, "data": { "challenge_id": data.challenge_id, "bg_image": data.bg_image, "piece_image": data.piece_image, }, "message": "ok" }))) } /// 登录前置闸门:失败计数锁定检查 + 滑块验证码校验。 /// /// 本函数由 `login` **原样搬出**,可执行语句、SQL、控制流、错误口径与执行顺序逐条不变: /// - 锁定检查放在验证码之前:锁定期直接拒绝,不消耗验证码、不做图像/DB 开销; /// 锁定期与密码错误返回**完全一致**的文案(`LOGIN_FAIL_MESSAGE`), /// 不泄露账号是否存在 / 是否被锁。 /// - 校验滑块验证码(防暴力破解 + 人机验证):缺失验证参数或验证码错误返回 `BadRequest`。 /// /// 返回 `Ok(())` 表示前置检查全部通过;所有错误口径与搬出前一致, /// 由调用方 `login` 以 `?` 原样上抛(`Unauthorized` / `BadRequest` 不变)。 async fn login_guard( state: &AppState, input: &LoginInput, phone_key: &str, ip_key: &str, ip: &str, ) -> Result<(), AppError> { // 锁定检查放在验证码之前:锁定期直接拒绝,不消耗验证码、不做图像/DB 开销 let phone_fails = read_counter(&state, &phone_key).await; let ip_fails = read_counter(&state, &ip_key).await; if phone_fails >= LOGIN_FAIL_MAX || ip_fails >= LOGIN_FAIL_MAX { tracing::warn!( phone = %input.phone, ip = %ip, phone_fails, ip_fails, "登录已锁定,拒绝本次尝试(返回统一文案)" ); return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into())); } // 校验滑块验证码(防暴力破解 + 人机验证) if input.challenge_id.is_empty() { return Err(AppError::BadRequest("缺少登录验证参数".into())); } let captcha_x = input.captcha_x.unwrap_or(i32::MAX); let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?; if !valid { return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into())); } Ok(()) } /// POST /api/auth/login — 用户登录 /// /// 防暴力破解:按「手机号 + 客户端 IP」双维度计数(Redis,TTL 15 分钟), /// 任一维度达到 5 次失败即锁定该维度;锁定期与密码错误返回**完全一致**的文案, /// 不泄露账号是否存在 / 是否被锁。登录成功后两个维度计数清零。 pub async fn login( State(state): State, headers: HeaderMap, Json(input): Json, ) -> Result, AppError> { let pool = &state.mysql; if input.phone.is_empty() || input.password.is_empty() { return Err(AppError::BadRequest("手机号和密码不能为空".into())); } let ip = client_ip(&headers); let phone_key = format!("auth:fail:phone:{}", input.phone); let ip_key = format!("auth:fail:ip:{}", ip); // 锁定检查 + 滑块验证码校验:原样搬入 login_guard,此处仅按原顺序调用 login_guard(&state, &input, &phone_key, &ip_key, &ip).await?; // 查询用户(含角色和组织信息) let user_row: Option<(i64, Option, String, String, i32, i32, Option)> = sqlx::query_as( "SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?", ) .bind(&input.phone) .fetch_optional(pool) .await?; let Some((id, name, phone, stored_password, status, role_level, organization_id)) = user_row else { // 账号不存在也计入失败:避免用"是否存在"做账号枚举 let fails = record_login_failure(&state, &phone_key, &ip_key, &input.phone, &ip).await; tracing::warn!(phone = %input.phone, ip = %ip, fails, reason = "user_not_found", "登录失败"); return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into())); }; // 检查账户状态 if status == 0 { return Err(AppError::Forbidden("账户已被禁用".into())); } // 密码校验(argon2 哈希比对) let valid = auth::verify_password(&input.password, &stored_password) .map_err(|_| AppError::Internal("密码验证失败".into()))?; if !valid { let fails = record_login_failure(&state, &phone_key, &ip_key, &phone, &ip).await; tracing::warn!(phone = %phone, ip = %ip, fails, reason = "bad_password", "登录失败"); return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into())); } // 登录成功:双维度失败计数清零 clear_counter(&state, &phone_key).await; clear_counter(&state, &ip_key).await; tracing::info!(user_id = id, phone = %phone, ip = %ip, "登录成功"); // 生成 JWT token let token = auth::generate_token(id, &phone, &state.jwt_secret)?; // 查询用户权限码 let permissions = auth::load_user_permissions(pool, id).await?; let perm_list: Vec = permissions.into_iter().collect(); Ok(Json(json!({ "code": 0, "data": { "token": token, "user": { "id": id, "name": name, "phone": phone, "role_level": role_level, "organization_id": organization_id, "permissions": perm_list, } }, "message": "登录成功" }))) } /// POST /api/auth/logout — 用户登出 pub async fn logout() -> Json { // JWT 无状态,客户端清除 token 即可 Json(json!({ "code": 0, "data": null, "message": "登出成功" })) } /// GET /api/auth/me — 获取当前用户信息及权限 #[allow(clippy::type_complexity)] pub async fn me( user: CurrentUser, State(state): State, ) -> Result, AppError> { let pool = &state.mysql; // 查询用户详细信息 let row: Option<(i64, Option, String, i32, Option, Option)> = sqlx::query_as( "SELECT id, name, phone, role, organization_id, avatar FROM users WHERE id = ?" ) .bind(user.user_id) .fetch_optional(pool) .await?; let (id, name, phone, role, org_id, avatar) = row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?; let perm_list: Vec = user.permissions.into_iter().collect(); Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name, "phone": phone, "role": role, "organization_id": org_id, "avatar": avatar, "permissions": perm_list, }, "message": "ok" }))) } /// 微信扫码登录请求体 #[derive(Debug, Deserialize)] pub struct WxLoginInput { /// wx.login() 获取的临时 code pub code: String, /// getPhoneNumber 获取的 phone code pub phone_code: String, } /// 微信 jscode2session 响应 /// /// 字段与微信开放平台返回体一一对齐(当前仅读取 openid/errmsg,其余为预留能力, /// 见各字段注释),故整体允许 dead_code,避免误删上游协议字段。 #[derive(Debug, Deserialize)] #[allow(dead_code)] struct JsCode2SessionResp { #[serde(default)] openid: Option, /// 会话密钥:预留给敏感数据解密(如 encryptedData/水印校验),当前登录流程不需要 #[serde(default)] session_key: Option, /// 微信错误码:预留用于后续按错误码细分排障(当前仅用 errmsg 文案) #[serde(default)] errcode: Option, #[serde(default)] errmsg: Option, } /// 微信 access_token 响应 /// /// 字段与微信开放平台返回体一一对齐(当前仅读取 access_token/errmsg,其余为预留能力), /// 故整体允许 dead_code。 #[derive(Debug, Deserialize)] #[allow(dead_code)] struct TokenResp { #[serde(default)] access_token: Option, /// 有效期秒数:预留用于 access_token 本地缓存(当前每次登录都重新获取) #[serde(default)] expires_in: Option, /// 微信错误码:预留用于后续按错误码细分排障 #[serde(default)] errcode: Option, #[serde(default)] errmsg: Option, } /// 微信 getuserphonenumber 响应 /// /// 字段与微信开放平台返回体一一对齐(当前仅读取 phone_info/errmsg),故整体允许 dead_code。 #[derive(Debug, Deserialize)] #[allow(dead_code)] struct PhoneNumberResp { /// 微信错误码:预留用于后续按错误码细分排障 #[serde(default)] errcode: Option, #[serde(default)] errmsg: Option, #[serde(default)] phone_info: Option, } /// 微信手机号信息 /// /// 当前业务用 pure_phone_number 匹配用户;带国码的 phone_number 与 country_code /// 预留用于国际号段/归属地展示,故整体允许 dead_code。 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] struct PhoneInfo { /// 带国码手机号(如 8613951516272),预留国际号段场景 #[serde(default)] phone_number: String, #[serde(default)] pure_phone_number: String, /// 国家码(如 86),预留号段归属展示 #[serde(default)] country_code: String, } /// 读取微信小程序凭证(环境变量缺失则报内部错误) fn wechat_credentials() -> Result<(String, String), AppError> { let appid = std::env::var("WECHAT_APPID") .map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?; let secret = std::env::var("WECHAT_APPSECRET") .map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?; Ok((appid, secret)) } /// 用 wx.login() 的 code 换取 openid async fn fetch_openid(client: &Client, appid: &str, secret: &str, code: &str) -> Result { let jscode_url = format!( "https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code", appid, secret, code ); let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await .map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))? .json().await .map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?; js_resp.openid.ok_or_else(|| { let err = js_resp.errmsg.unwrap_or_default(); AppError::Internal(format!("jscode2session 失败: {}", err)) }) } /// 获取微信接口调用凭证 access_token async fn fetch_access_token(client: &Client, appid: &str, secret: &str) -> Result { let token_url = format!( "https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential", appid, secret ); let token_resp: TokenResp = client.get(&token_url).send().await .map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))? .json().await .map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?; token_resp.access_token.ok_or_else(|| { let err = token_resp.errmsg.unwrap_or_default(); AppError::Internal(format!("获取 access_token 失败: {}", err)) }) } /// 用 phone_code 换取微信绑定的手机号 async fn fetch_phone_number( client: &Client, access_token: &str, phone_code: &str, ) -> Result { let phone_url = format!( "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}", access_token ); let phone_resp: PhoneNumberResp = client.post(&phone_url) .json(&json!({ "code": phone_code })) .send().await .map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))? .json().await .map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?; let phone = phone_resp.phone_info .ok_or_else(|| { let err = phone_resp.errmsg.unwrap_or_default(); AppError::Internal(format!("获取手机号失败: {}", err)) })?; Ok(phone.pure_phone_number) } /// 按手机号查找用户(手机号是唯一标识) async fn find_user_by_phone( pool: &sqlx::MySqlPool, phone_number: &str, ) -> Result<(i64, Option, String, i32, i32, Option), AppError> { let user_row = sqlx::query_as::<_, (i64, Option, String, i32, i32, Option)>( "SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?", ) .bind(phone_number) .fetch_optional(pool) .await?; user_row.ok_or_else(|| { tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户"); AppError::Unauthorized("用户不存在,请联系管理员添加".into()) }) } /// POST /api/auth/wx-login — 微信小程序手机号快速登录 /// /// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code, /// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。 pub async fn wx_login( State(state): State, Json(input): Json, ) -> Result, AppError> { let (appid, secret) = wechat_credentials()?; let client = Client::new(); // 1. 用 code 换取 openid let openid = fetch_openid(&client, &appid, &secret, &input.code).await?; // 2. 获取 access_token let access_token = fetch_access_token(&client, &appid, &secret).await?; // 3. 用 phone_code 换取手机号 let phone_number = fetch_phone_number(&client, &access_token, &input.phone_code).await?; // 4. 按手机号查找用户(手机号是唯一标识) tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户"); let (id, name, _phone, status, role_level, organization_id) = find_user_by_phone(&state.mysql, &phone_number).await?; // 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息) sqlx::query("UPDATE users SET openid = ? WHERE id = ?") .bind(&openid) .bind(id) .execute(&state.mysql) .await?; if status == 0 { return Err(AppError::Forbidden("账户已被禁用".into())); } let token = auth::generate_token(id, &_phone, &state.jwt_secret)?; let permissions = auth::load_user_permissions(&state.mysql, id).await?; let perm_list: Vec = permissions.into_iter().collect(); Ok(Json(json!({ "code": 0, "data": { "token": token, "user": { "id": id, "name": name, "phone": _phone, "role_level": role_level, "organization_id": organization_id, "permissions": perm_list, } }, "message": "登录成功" }))) } /// 微信头像更新请求体 #[derive(Debug, Deserialize)] pub struct AvatarInput { pub avatar: String, } /// PUT /api/auth/avatar — 更新当前用户头像 URL pub async fn update_avatar( user: CurrentUser, State(state): State, Json(input): Json, ) -> Result, AppError> { sqlx::query("UPDATE users SET avatar = ? WHERE id = ?") .bind(&input.avatar) .bind(user.user_id) .execute(&state.mysql) .await?; Ok(Json(json!({ "code": 0, "data": { "avatar": input.avatar }, "message": "ok" }))) } /// GET /api/auth/avatar/upload — 头像文件上传 pub async fn upload_avatar( user: CurrentUser, State(state): State, mut multipart: Multipart, ) -> Result, AppError> { let mut file_data: Option> = None; let mut ext = "".to_string(); while let Some(field) = multipart.next_field().await .map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? { let filename = field.file_name().unwrap_or("").to_string(); let data = field.bytes().await .map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?; // 提取扩展名 if let Some(pos) = filename.rfind('.') { ext = filename[pos..].to_string(); } file_data = Some(data.to_vec()); } let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?; if data.is_empty() { return Err(AppError::BadRequest("上传内容为空".into())); } // 限制大小(2MB) if data.len() > 2 * 1024 * 1024 { return Err(AppError::BadRequest("头像文件不能超过 2MB".into())); } // 扩展名白名单 + 文件魔数嗅探(拒绝 .svg/.htm 等可脚本化类型,防存储型 XSS) // 返回值是**按内容嗅探出的**规范扩展名,保证落盘后缀与真实内容一致 let ext = upload_guard::validate_image_upload(&data, &ext)?; // 保存文件 let dir = std::path::Path::new(AVATAR_DIR); std::fs::create_dir_all(dir) .map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?; let filename = format!("{}{}", Uuid::new_v4().simple(), ext); let filepath = dir.join(&filename); let mut f = std::fs::File::create(&filepath) .map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?; f.write_all(&data) .map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?; // 更新用户头像 let avatar_url = format!("/uploads/avatars/{}", filename); sqlx::query("UPDATE users SET avatar = ? WHERE id = ?") .bind(&avatar_url) .bind(user.user_id) .execute(&state.mysql) .await?; tracing::info!(user_id = user.user_id, path = %avatar_url, "头像上传成功"); Ok(Json(json!({ "code": 0, "data": { "avatar": avatar_url }, "message": "ok" }))) }