522 lines
18 KiB
C#
522 lines
18 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using DramaLing.Api.Data;
|
|
using DramaLing.Api.Models.Entities;
|
|
using DramaLing.Api.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace DramaLing.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class AuthController : ControllerBase
|
|
{
|
|
private readonly DramaLingDbContext _context;
|
|
private readonly IAuthService _authService;
|
|
private readonly ILogger<AuthController> _logger;
|
|
|
|
public AuthController(
|
|
DramaLingDbContext context,
|
|
IAuthService authService,
|
|
ILogger<AuthController> logger)
|
|
{
|
|
_context = context;
|
|
_authService = authService;
|
|
_logger = logger;
|
|
}
|
|
|
|
[HttpPost("register")]
|
|
public async Task<ActionResult> Register([FromBody] RegisterRequest request)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Registration attempt for user: {Username}, Email: {Email}",
|
|
request.Username, request.Email);
|
|
|
|
// 驗證請求
|
|
if (!ModelState.IsValid)
|
|
{
|
|
_logger.LogWarning("Invalid model state for registration: {@ModelErrors}",
|
|
ModelState.Where(x => x.Value?.Errors.Count > 0)
|
|
.ToDictionary(x => x.Key, x => x.Value?.Errors.Select(e => e.ErrorMessage)));
|
|
return BadRequest(new { Success = false, Error = "Invalid request data" });
|
|
}
|
|
|
|
// 檢查Email是否已存在
|
|
if (await _context.Users.AnyAsync(u => u.Email == request.Email))
|
|
return BadRequest(new { Success = false, Error = "Email already exists" });
|
|
|
|
// 檢查用戶名是否已存在
|
|
if (await _context.Users.AnyAsync(u => u.Username == request.Username))
|
|
return BadRequest(new { Success = false, Error = "Username already exists" });
|
|
|
|
// 雜湊密碼
|
|
var passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
|
|
|
// 建立新用戶
|
|
var user = new User
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Username = request.Username,
|
|
Email = request.Email,
|
|
PasswordHash = passwordHash,
|
|
DisplayName = request.Username, // 預設使用用戶名作為顯示名稱
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.Users.Add(user);
|
|
await _context.SaveChangesAsync();
|
|
|
|
// 生成JWT Token
|
|
var token = GenerateJwtToken(user);
|
|
|
|
_logger.LogInformation("User registered successfully: {UserId}", user.Id);
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = new
|
|
{
|
|
Token = token,
|
|
User = new
|
|
{
|
|
user.Id,
|
|
user.Username,
|
|
user.Email,
|
|
user.DisplayName,
|
|
user.AvatarUrl,
|
|
user.SubscriptionType
|
|
}
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error during user registration. Request: {@Request}", new {
|
|
Username = request.Username,
|
|
Email = request.Email,
|
|
StackTrace = ex.StackTrace,
|
|
InnerException = ex.InnerException?.Message
|
|
});
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Registration failed",
|
|
Details = ex.Message,
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpPost("login")]
|
|
public async Task<ActionResult> Login([FromBody] LoginRequest request)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Login attempt for email: {Email}", request.Email);
|
|
|
|
// 驗證請求
|
|
if (!ModelState.IsValid)
|
|
{
|
|
_logger.LogWarning("Invalid model state for login: {@ModelErrors}",
|
|
ModelState.Where(x => x.Value?.Errors.Count > 0)
|
|
.ToDictionary(x => x.Key, x => x.Value?.Errors.Select(e => e.ErrorMessage)));
|
|
return BadRequest(new { Success = false, Error = "Invalid request data" });
|
|
}
|
|
|
|
// 查找用戶
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.Email == request.Email);
|
|
if (user == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid email or password" });
|
|
|
|
// 驗證密碼
|
|
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
|
return Unauthorized(new { Success = false, Error = "Invalid email or password" });
|
|
|
|
// 生成JWT Token
|
|
var token = GenerateJwtToken(user);
|
|
|
|
_logger.LogInformation("User logged in successfully: {UserId}", user.Id);
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = new
|
|
{
|
|
Token = token,
|
|
User = new
|
|
{
|
|
user.Id,
|
|
user.Username,
|
|
user.Email,
|
|
user.DisplayName,
|
|
user.AvatarUrl,
|
|
user.SubscriptionType
|
|
}
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error during user login. Request: {@Request}", new {
|
|
Email = request.Email,
|
|
StackTrace = ex.StackTrace,
|
|
InnerException = ex.InnerException?.Message
|
|
});
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Login failed",
|
|
Details = ex.Message,
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
private string GenerateJwtToken(User user)
|
|
{
|
|
var jwtSecret = Environment.GetEnvironmentVariable("DRAMALING_SUPABASE_JWT_SECRET")
|
|
?? Environment.GetEnvironmentVariable("DRAMALING_JWT_SECRET")
|
|
?? "dev-secret-minimum-32-characters-long-for-jwt-signing-in-development-mode-only";
|
|
|
|
var tokenHandler = new JwtSecurityTokenHandler();
|
|
var key = Encoding.UTF8.GetBytes(jwtSecret);
|
|
|
|
var tokenDescriptor = new SecurityTokenDescriptor
|
|
{
|
|
Subject = new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
|
new Claim("sub", user.Id.ToString()),
|
|
new Claim("email", user.Email),
|
|
new Claim("username", user.Username),
|
|
new Claim("name", user.DisplayName ?? user.Username)
|
|
}),
|
|
Expires = DateTime.UtcNow.AddDays(7),
|
|
Issuer = Environment.GetEnvironmentVariable("DRAMALING_SUPABASE_URL") ?? "http://localhost:5000",
|
|
Audience = "authenticated",
|
|
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256)
|
|
};
|
|
|
|
var token = tokenHandler.CreateToken(tokenDescriptor);
|
|
return tokenHandler.WriteToken(token);
|
|
}
|
|
|
|
[HttpGet("profile")]
|
|
[Authorize]
|
|
public async Task<ActionResult> GetProfile()
|
|
{
|
|
try
|
|
{
|
|
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
|
if (userId == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
|
|
|
// 如果用戶不存在,從 JWT 令牌建立基本資料
|
|
if (user == null)
|
|
{
|
|
var claimsPrincipal = await _authService.ValidateTokenAsync(
|
|
Request.Headers.Authorization.ToString()["Bearer ".Length..].Trim());
|
|
|
|
if (claimsPrincipal == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
|
|
|
var email = claimsPrincipal.FindFirst("email")?.Value ?? "";
|
|
var displayName = claimsPrincipal.FindFirst("name")?.Value ??
|
|
claimsPrincipal.FindFirst("user_metadata")?.Value ??
|
|
email.Split('@')[0];
|
|
|
|
user = new User
|
|
{
|
|
Id = userId.Value,
|
|
Email = email,
|
|
DisplayName = displayName,
|
|
SubscriptionType = "free"
|
|
};
|
|
|
|
_context.Users.Add(user);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Created new user profile for {UserId}", userId);
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = new
|
|
{
|
|
user.Id,
|
|
user.Email,
|
|
user.DisplayName,
|
|
user.AvatarUrl,
|
|
user.SubscriptionType,
|
|
user.CreatedAt
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching user profile");
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Failed to fetch profile",
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpPut("profile")]
|
|
[Authorize]
|
|
public async Task<ActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
|
{
|
|
try
|
|
{
|
|
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
|
if (userId == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
|
if (user == null)
|
|
return NotFound(new { Success = false, Error = "User not found" });
|
|
|
|
// 更新用戶資料
|
|
if (!string.IsNullOrWhiteSpace(request.DisplayName))
|
|
{
|
|
if (request.DisplayName.Length > 100)
|
|
return BadRequest(new { Success = false, Error = "Display name must be less than 100 characters" });
|
|
|
|
user.DisplayName = request.DisplayName.Trim();
|
|
}
|
|
|
|
if (request.AvatarUrl != null)
|
|
user.AvatarUrl = request.AvatarUrl?.Trim();
|
|
|
|
if (request.Preferences != null)
|
|
user.Preferences = request.Preferences;
|
|
|
|
user.UpdatedAt = DateTime.UtcNow;
|
|
await _context.SaveChangesAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = user,
|
|
Message = "Profile updated successfully"
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating user profile");
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Failed to update profile",
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpGet("settings")]
|
|
[Authorize]
|
|
public async Task<ActionResult> GetSettings()
|
|
{
|
|
try
|
|
{
|
|
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
|
if (userId == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
|
|
|
var settings = await _context.UserSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
|
|
|
// 如果沒有設定,建立預設設定
|
|
if (settings == null)
|
|
{
|
|
settings = new UserSettings
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = userId.Value,
|
|
DailyGoal = 20,
|
|
ReminderTime = new TimeOnly(9, 0),
|
|
ReminderEnabled = true,
|
|
DifficultyPreference = "balanced",
|
|
AutoPlayAudio = true,
|
|
ShowPronunciation = true
|
|
};
|
|
|
|
_context.UserSettings.Add(settings);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Created default settings for user {UserId}", userId);
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = settings
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching user settings");
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Failed to fetch settings",
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpPut("settings")]
|
|
[Authorize]
|
|
public async Task<ActionResult> UpdateSettings([FromBody] UpdateSettingsRequest request)
|
|
{
|
|
try
|
|
{
|
|
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
|
if (userId == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
|
|
|
var settings = await _context.UserSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
|
if (settings == null)
|
|
return NotFound(new { Success = false, Error = "Settings not found" });
|
|
|
|
// 驗證並更新設定
|
|
if (request.DailyGoal.HasValue)
|
|
{
|
|
if (request.DailyGoal < 1 || request.DailyGoal > 100)
|
|
return BadRequest(new { Success = false, Error = "Daily goal must be between 1 and 100" });
|
|
|
|
settings.DailyGoal = request.DailyGoal.Value;
|
|
}
|
|
|
|
if (request.ReminderTime.HasValue)
|
|
settings.ReminderTime = request.ReminderTime.Value;
|
|
|
|
if (request.ReminderEnabled.HasValue)
|
|
settings.ReminderEnabled = request.ReminderEnabled.Value;
|
|
|
|
if (!string.IsNullOrEmpty(request.DifficultyPreference))
|
|
{
|
|
if (!new[] { "conservative", "balanced", "aggressive" }.Contains(request.DifficultyPreference))
|
|
return BadRequest(new { Success = false, Error = "Invalid difficulty preference" });
|
|
|
|
settings.DifficultyPreference = request.DifficultyPreference;
|
|
}
|
|
|
|
if (request.AutoPlayAudio.HasValue)
|
|
settings.AutoPlayAudio = request.AutoPlayAudio.Value;
|
|
|
|
if (request.ShowPronunciation.HasValue)
|
|
settings.ShowPronunciation = request.ShowPronunciation.Value;
|
|
|
|
settings.UpdatedAt = DateTime.UtcNow;
|
|
await _context.SaveChangesAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = settings,
|
|
Message = "Settings updated successfully"
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating user settings");
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Failed to update settings",
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 檢查用戶認證狀態 (無需資料庫查詢的快速檢查)
|
|
/// </summary>
|
|
[HttpGet("status")]
|
|
[Authorize]
|
|
public async Task<ActionResult> GetAuthStatus()
|
|
{
|
|
try
|
|
{
|
|
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
|
if (userId == null)
|
|
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
|
|
|
return Ok(new
|
|
{
|
|
Success = true,
|
|
Data = new
|
|
{
|
|
IsAuthenticated = true,
|
|
UserId = userId,
|
|
Timestamp = DateTime.UtcNow
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error checking auth status");
|
|
return StatusCode(500, new
|
|
{
|
|
Success = false,
|
|
Error = "Failed to check auth status",
|
|
Timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Request DTOs
|
|
public class RegisterRequest
|
|
{
|
|
[Required(ErrorMessage = "Username is required")]
|
|
[StringLength(50, MinimumLength = 3, ErrorMessage = "Username must be between 3 and 50 characters")]
|
|
public string Username { get; set; } = string.Empty;
|
|
|
|
[Required(ErrorMessage = "Email is required")]
|
|
[EmailAddress(ErrorMessage = "Invalid email format")]
|
|
public string Email { get; set; } = string.Empty;
|
|
|
|
[Required(ErrorMessage = "Password is required")]
|
|
[StringLength(100, MinimumLength = 8, ErrorMessage = "Password must be at least 8 characters")]
|
|
public string Password { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class LoginRequest
|
|
{
|
|
[Required(ErrorMessage = "Email is required")]
|
|
[EmailAddress(ErrorMessage = "Invalid email format")]
|
|
public string Email { get; set; } = string.Empty;
|
|
|
|
[Required(ErrorMessage = "Password is required")]
|
|
public string Password { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class UpdateProfileRequest
|
|
{
|
|
public string? DisplayName { get; set; }
|
|
public string? AvatarUrl { get; set; }
|
|
public Dictionary<string, object>? Preferences { get; set; }
|
|
}
|
|
|
|
public class UpdateSettingsRequest
|
|
{
|
|
public int? DailyGoal { get; set; }
|
|
public TimeOnly? ReminderTime { get; set; }
|
|
public bool? ReminderEnabled { get; set; }
|
|
public string? DifficultyPreference { get; set; }
|
|
public bool? AutoPlayAudio { get; set; }
|
|
public bool? ShowPronunciation { get; set; }
|
|
} |