flower-story/Server/Controller/UserApiController.cs
2024-10-12 14:34:11 +08:00

495 lines
16 KiB
C#

using Blahblah.FlowerStory.Server.Data;
using Blahblah.FlowerStory.Server.Data.Model;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
namespace Blahblah.FlowerStory.Server.Controller;
/// <summary>
/// 用户会话相关 API 服务
/// </summary>
[ApiController]
[Produces("application/json")]
[Route("api/user")]
public partial class UserApiController(FlowerDatabase db, ILogger<UserApiController> logger) : BaseController<UserApiController>(db, logger)
{
/// <summary>
/// 用户登录
/// </summary>
/// <remarks>
/// 请求示例:
///
/// POST /api/user/auth
/// {
/// "id": "blahblah",
/// "password": "pwd123"
/// }
///
/// </remarks>
/// <param name="login">登录参数</param>
/// <returns>成功登录则返回自定义认证头</returns>
/// <response code="200">返回用户对象,返回头中包含认证信息</response>
/// <response code="401">认证失败</response>
/// <response code="404">未找到用户</response>
/// <response code="500">服务器错误</response>
[Route("auth", Name = "authenticate")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpPost]
[Consumes("application/json")]
public ActionResult<UserItem> Authenticate([FromBody] LoginParamter login)
{
#if DEBUG
logger?.LogInformation("user \"{user}\" try to login with password \"{password}\"", login.Id, login.Password);
#endif
//var user = database.Users.SingleOrDefault(u => u.UserId == login.Id);
var user = QueryUserItemForAuthentication(login.Id);
if (user == null)
{
logger?.LogWarning("user \"{user}\" not found", login.Id);
return NotFound();
}
// compute password hash with salt
string hash = HashPassword(login.Password, login.Id);
if (hash != user.Password)
{
logger?.LogWarning("hash result {hash}, unauthorized with hash {password}", hash, user.Password);
return Unauthorized();
}
// record the session
// TODO: singleton token
string clientApp;
int expires;
if (Request.Headers.TryGetValue("X-ClientAgent", out var clientAgent))
{
clientApp = "app";
expires = 30 * 24 * 60 * 60; // 30 days
}
else
{
clientApp = "browser";
expires = 20 * 60; // 20 mins
}
database.Tokens.Where(t => t.UserId == user.Id && t.ClientApp == clientApp).ExecuteDelete();
var now = DateTimeOffset.UtcNow;
var token = new TokenItem
{
Id = Convert.ToBase64String(SHA256.HashData(Guid.NewGuid().ToByteArray())),
UserId = user.Id,
LogonDateUnixTime = now.ToUnixTimeMilliseconds(),
ActiveDateUnixTime = now.ToUnixTimeMilliseconds(),
ExpireDateUnixTime = now.AddSeconds(expires).ToUnixTimeMilliseconds(),
ExpireSeconds = expires,
ClientApp = clientApp,
ClientAgent = clientAgent.Count > 0 ? clientAgent : Request.Headers.UserAgent,
DeviceId = Request.Headers.TryGetValue("X-DeviceId", out var deviceId) ? deviceId.ToString() : null
};
database.Tokens.Add(token);
user.ActiveDateUnixTime = token.ActiveDateUnixTime;
SaveDatabase();
Response.Headers.Append(AuthHeader, token.Id);
return Ok(user);
}
/// <summary>
/// 判断当前会话是否有效
/// </summary>
/// <remarks>
/// 请求示例:
///
/// GET /api/user/validation
/// Authorization: authorization id
///
/// </remarks>
/// <returns>会话有效则返回会话对象</returns>
/// <response code="200">返回会话对象</response>
/// <response code="401">认证失败</response>
[Route("validation", Name = "validation")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public ActionResult<TokenItem> Validation()
{
var (result, token) = CheckToken();
if (result != null)
{
return result;
}
if (token == null)
{
return Unauthorized();
}
// update last active time
SaveDatabase();
return Ok(token);
}
/// <summary>
/// 注销当前登录会话
/// </summary>
/// <remarks>
/// 请求示例:
///
/// POST /api/user/logout
/// Authorization: authorization id
///
/// </remarks>
/// <returns>注销失败则返回错误内容</returns>
/// <response code="204">注销成功</response>
/// <response code="401">认证失败</response>
[Route("logout", Name = "logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpPost]
public ActionResult Logout()
{
var (result, token) = CheckToken();
if (result != null)
{
return result;
}
if (token == null)
{
return Unauthorized();
}
database.Tokens.Remove(token);
SaveDatabase();
return NoContent();
}
/// <summary>
/// 注册用户
/// </summary>
/// <remarks>
/// 请求示例:
///
/// POST /api/user/register
///
/// 参数:
///
/// id: "blahblah"
/// password: "pwd123"
/// name: "Blah blah"
/// email: "blah@example.com"
/// mobile: "18012345678"
/// avatar: &lt;avatar&gt;
///
/// </remarks>
/// <param name="user">注册参数</param>
/// <returns>成功注册则返回已注册的用户对象</returns>
/// <response code="200">返回已注册的用户对象</response>
/// <response code="400">用户头像格式非法</response>
/// <response code="500">用户重复或其他服务器错误</response>
[Route("register", Name = "register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpPost]
[Consumes("multipart/form-data")]
[RequestSizeLimit(15 * 1024 * 1024)]
public ActionResult<UserItem> Register([FromForm] UserParameter user)
{
#if DEBUG
logger?.LogInformation("user register, {user}", user);
#endif
if (database.Users.Any(u => u.UserId == user.Id))
{
logger?.LogWarning("duplicate user \"{id}\"", user.Id);
return Problem("duplicateUser", "api/user/register");
}
byte[]? data;
if (user.Avatar != null)
{
var avatar = WrapFormFile(user.Avatar);
if (avatar == null)
{
return BadRequest();
}
data = CreateThumbnail(avatar.Content);
}
else
{
data = null;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var item = new UserItem
{
UserId = user.Id,
Password = HashPassword(user.Password, user.Id),
Level = UserCommon,
RegisterDateUnixTime = now,
ActiveDateUnixTime = now,
Name = user.UserName,
Email = user.Email,
Mobile = user.Mobile,
Avatar = data
};
database.Users.Add(item);
SaveDatabase();
return Ok(item);
}
/// <summary>
/// 查询当前会话关联的用户
/// </summary>
/// <remarks>
/// 请求示例:
///
/// GET /api/user/profile
/// Authorization: authorization id
///
/// </remarks>
/// <returns>会话有效则返回关联的用户对象</returns>
/// <response code="200">返回关联的用户对象</response>
/// <response code="401">未找到登录会话或已过期</response>
/// <response code="403">用户已禁用</response>
/// <response code="404">未找到关联用户</response>
[Route("profile", Name = "getProfile")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public ActionResult<UserItem> Profile()
{
var (result, user) = CheckPermission();
if (result != null)
{
return result;
}
if (user == null)
{
return NotFound();
}
// update last active time
SaveDatabase();
return Ok(user);
}
/// <summary>
/// 修改用户
/// </summary>
/// <remarks>
/// 请求示例:
///
/// PUT /api/user/update
/// Authorization: authorization id
///
/// 参数:
///
/// name": "Blah blah"
/// email": "blah@example.com"
/// mobile": "18012345678",
/// avatar: &lt;avatar&gt;
///
/// </remarks>
/// <param name="update">修改参数</param>
/// <returns>修改成功则返回已修改的用户对象</returns>
/// <response code="200">返回已修改的用户对象</response>
/// <response code="400">用户头像格式非法</response>
/// <response code="401">未找到登录会话或已过期</response>
/// <response code="403">用户已禁用</response>
/// <response code="404">未找到关联用户</response>
/// <response code="413">提交正文过大</response>
[Route("update", Name = "updateProfile")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpPut]
[Consumes("multipart/form-data")]
[RequestSizeLimit(15 * 1024 * 1024)]
public ActionResult<UserItem> Update([FromForm] UpdateParameter update)
{
#if DEBUG
logger?.LogInformation("user update, {user}", update);
#endif
var (result, user) = CheckPermission();
if (result != null)
{
return result;
}
if (user == null)
{
return NotFound();
}
if (update.Avatar != null)
{
var avatar = WrapFormFile(update.Avatar);
if (avatar == null)
{
return BadRequest();
}
user.Avatar = CreateThumbnail(avatar.Content);
}
user.Name = update.UserName;
user.Email = update.Email;
user.Mobile = update.Mobile;
SaveDatabase();
return Ok(user);
}
/// <summary>
/// 修改用户头像
/// </summary>
/// <remarks>
/// 请求示例:
///
/// PUT /api/user/update_avatar
/// Authorization: authorization id
///
/// 参数:
///
/// avatar: IFormFile
///
/// </remarks>
/// <param name="avatar">用户头像</param>
/// <returns>修改成功则返回 HTTP 204</returns>
/// <response code="204">修改成功</response>
/// <response code="400">头像内容格式非法</response>
/// <response code="401">未找到登录会话或已过期</response>
/// <response code="403">用户已禁用</response>
/// <response code="404">未找到关联用户</response>
/// <response code="413">提交正文过大</response>
[Route("update_avatar", Name = "updateAvatar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpPut]
[Consumes("multipart/form-data")]
[RequestSizeLimit(5 * 1024 * 1024)]
public ActionResult<UserItem> UploadAvatar([Required] IFormFile avatar)
{
#if DEBUG
logger?.LogInformation("user update avatar, {avatar}", avatar);
#endif
var (result, user) = CheckPermission();
if (result != null)
{
return result;
}
if (user == null)
{
return NotFound();
}
if (avatar.Length > 0)
{
var file = WrapFormFile(avatar);
if (file == null)
{
return BadRequest();
}
user.Avatar = CreateThumbnail(file.Content);
}
SaveDatabase();
return NoContent();
}
/// <summary>
/// 移除用户头像
/// </summary>
/// <remarks>
/// 请求示例:
///
/// DELETE /api/user/remove_avatar
/// Authorization: authorization id
///
/// </remarks>
/// <returns>移除成功则返回修改的数据库行数</returns>
/// <response code="200">修改的数据库行数</response>
/// <response code="401">未找到登录会话或已过期</response>
/// <response code="403">用户已禁用</response>
/// <response code="404">未找到关联用户</response>
[Route("remove_avatar", Name = "removeAvatar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpDelete]
public ActionResult RemoveAvatar()
{
var (result, user) = CheckPermission();
if (result != null)
{
return result;
}
if (user == null)
{
return NotFound();
}
var count = RemoveUserAvatar(user.Id);
SaveDatabase();
return Ok(count);
}
#if !PRODUCTION
/// <summary>
/// #DEBUG 获取所有用户
/// </summary>
/// <returns></returns>
[Route("debug_list", Name = "debug_list")]
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public ActionResult<UserItem[]> GetUsers()
{
return Ok(database.Users.ToArray());
}
/// <summary>
/// #DEBUG 获取所有 token
/// </summary>
/// <returns></returns>
[Route("debug_tokens", Name = "debug_tokens")]
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public ActionResult<TokenItem[]> GetTokens()
{
return Ok(database.Tokens.ToArray());
}
#endif
}