Skip to content

Commit 0c8d80d

Browse files
feat(server): 支持Hikarinagi第三方登录
1 parent 2ea2cee commit 0c8d80d

13 files changed

Lines changed: 315 additions & 10 deletions

File tree

.kilocode/rules/project-info-galgamemanager-server.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,11 @@ This `InformationalVersion` is then retrieved in C# using:
120120
* **Database Migrations:** Changes to server-side entities (like adding `PlayCount` to `Galgame.cs`) require new EF Core migrations to be created and applied to the database.
121121
* **DTO vs. Entity:** Understand the distinction between DTOs (for API communication, e.g., `GalgameUpdateDto`) and Entities (for database representation, e.g., `Galgame.cs`). AutoMapper is often used for mapping between them, but in this service, it's done manually for updates.
122122

123+
## 7. Hikarinagi OAuth Proxy
124+
125+
* `HikarinagiController` and `HikarinagiService` provide the confidential-server portion of Hikarinagi's Authorization Code flow with PKCE. The desktop client owns and verifies `state` and retains the PKCE verifier; the server stores the Hikarinagi client secret and exchanges authorization codes and refresh tokens.
126+
* Configure `AppSettings:Hikarinagi:OAuth2Enable`, `ClientId`, `ClientSecret`, and `RedirectUri`. Enabling either the Hikarinagi catalog proxy or OAuth requires the client credentials; enabling OAuth also requires the redirect URI, normally `potato-vn://oauth-hikarinagi`.
127+
* The authorization request uses scopes `status:write offline_access`, `prompt=consent`, and PKCE S256. Hikarinagi refresh tokens rotate, so every successful refresh response must be returned to the client and persisted in place of the previous token.
128+
* `/server/info` advertises OAuth availability through `HikarinagiOAuth2Enable`. This flag is independent from the anonymous Hikarinagi catalog proxy setting.
129+
123130
This document provides a foundational knowledge base for the `GalgameManager.Server`. For specific implementation details, direct code analysis of the mentioned files and directories will be necessary.

GalgameManager.Server.Test/Services/HikarinagiServiceTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,84 @@ private static IConfiguration CreateConfig(bool enable = true)
1818
["AppSettings:Hikarinagi:Enable"] = enable.ToString(),
1919
["AppSettings:Hikarinagi:ClientId"] = "test_id",
2020
["AppSettings:Hikarinagi:ClientSecret"] = "test_secret",
21+
["AppSettings:Hikarinagi:OAuth2Enable"] = enable.ToString(),
22+
["AppSettings:Hikarinagi:RedirectUri"] = "potato-vn://oauth-hikarinagi",
2123
}).Build();
2224
}
2325

26+
[Test]
27+
public void GetAuthorizationUrl_IncludesPkceStateAndUserScopes()
28+
{
29+
// Arrange
30+
HikarinagiService service = new(CreateConfig(), new HttpClient(new StubHttpMessageHandler()));
31+
32+
// Act
33+
string url = service.GetAuthorizationUrl("state_value", "challenge_value");
34+
35+
// Assert
36+
Assert.Multiple(() =>
37+
{
38+
Assert.That(url, Does.StartWith("https://id.hikarinagi.org/oidc/auth?"));
39+
Assert.That(url, Does.Contain("client_id=test_id"));
40+
Assert.That(url, Does.Contain("redirect_uri=potato-vn%3a%2f%2foauth-hikarinagi"));
41+
Assert.That(url, Does.Contain("scope=status%3awrite+offline_access"));
42+
Assert.That(url, Does.Contain("state=state_value"));
43+
Assert.That(url, Does.Contain("code_challenge=challenge_value"));
44+
Assert.That(url, Does.Contain("code_challenge_method=S256"));
45+
});
46+
}
47+
48+
[Test]
49+
public async Task GetUserTokenWithCodeAsync_UsesConfidentialClientAndPkce()
50+
{
51+
// Arrange
52+
StubHttpMessageHandler handler = new();
53+
handler.EnqueueJson(HttpStatusCode.OK,
54+
"""{"access_token":"access_1","refresh_token":"refresh_1","expires_in":3600,"token_type":"Bearer","scope":"status:write"}""");
55+
HikarinagiService service = new(CreateConfig(), new HttpClient(handler));
56+
57+
// Act
58+
var token = await service.GetUserTokenWithCodeAsync("code_value", "verifier_value");
59+
60+
// Assert
61+
Assert.Multiple(() =>
62+
{
63+
Assert.That(token.AccessToken, Is.EqualTo("access_1"));
64+
Assert.That(token.RefreshToken, Is.EqualTo("refresh_1"));
65+
Assert.That(token.Scope, Is.EqualTo("status:write"));
66+
Assert.That(token.Expires, Is.GreaterThan(DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
67+
Assert.That(handler.Requests[0].Authorization, Is.EqualTo(
68+
"Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test_id:test_secret"))));
69+
Assert.That(handler.Requests[0].Body, Does.Contain("grant_type=authorization_code"));
70+
Assert.That(handler.Requests[0].Body, Does.Contain("code=code_value"));
71+
Assert.That(handler.Requests[0].Body, Does.Contain("code_verifier=verifier_value"));
72+
Assert.That(handler.Requests[0].Body,
73+
Does.Contain("redirect_uri=potato-vn%3A%2F%2Foauth-hikarinagi"));
74+
});
75+
}
76+
77+
[Test]
78+
public async Task GetUserTokenWithRefreshTokenAsync_ReturnsRotatedRefreshToken()
79+
{
80+
// Arrange
81+
StubHttpMessageHandler handler = new();
82+
handler.EnqueueJson(HttpStatusCode.OK,
83+
"""{"access_token":"access_2","refresh_token":"refresh_2","expires_in":3600,"token_type":"Bearer","scope":"status:write"}""");
84+
HikarinagiService service = new(CreateConfig(), new HttpClient(handler));
85+
86+
// Act
87+
var token = await service.GetUserTokenWithRefreshTokenAsync("refresh_1");
88+
89+
// Assert
90+
Assert.Multiple(() =>
91+
{
92+
Assert.That(token.AccessToken, Is.EqualTo("access_2"));
93+
Assert.That(token.RefreshToken, Is.EqualTo("refresh_2"));
94+
Assert.That(handler.Requests[0].Body, Does.Contain("grant_type=refresh_token"));
95+
Assert.That(handler.Requests[0].Body, Does.Contain("refresh_token=refresh_1"));
96+
});
97+
}
98+
2499
[Test]
25100
public async Task ProxyAsync_FetchesTokenAndProxiesRequest()
26101
{

GalgameManager.Server/Contracts/Service/IHikarinagiService.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ namespace GalgameManager.Server.Contracts;
55
public interface IHikarinagiService
66
{
77
public bool IsEnable { get; }
8+
public bool IsOAuth2Enable { get; }
9+
10+
/// <summary>生成包含用户状态读写权限、offline_access和PKCE参数的授权地址</summary>
11+
public string GetAuthorizationUrl(string state, string codeChallenge);
12+
13+
/// <summary>使用授权码和PKCE verifier换取用户令牌</summary>
14+
public Task<HikarinagiToken> GetUserTokenWithCodeAsync(string code, string codeVerifier);
15+
16+
/// <summary>刷新用户令牌;响应中的refresh token已轮换</summary>
17+
public Task<HikarinagiToken> GetUserTokenWithRefreshTokenAsync(string refreshToken);
818

919
/// <summary>
1020
/// 透传GET请求至Hikarinagi开放API,自动附加访问令牌,需要在外部捕获异常
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using GalgameManager.Server.Contracts;
3+
using GalgameManager.Server.Models;
4+
using Microsoft.AspNetCore.Mvc;
5+
6+
namespace GalgameManager.Server.Controllers;
7+
8+
[Route("[controller]")]
9+
[ApiController]
10+
public class HikarinagiController(IHikarinagiService hikarinagiService, ILogger<HikarinagiController> logger)
11+
: ControllerBase
12+
{
13+
/// <summary>生成Hikarinagi用户授权地址</summary>
14+
/// <response code="302">跳转至Hikarinagi ID授权页</response>
15+
/// <response code="400">PKCE或state参数无效</response>
16+
/// <response code="503">Hikarinagi OAuth服务没有启用</response>
17+
[HttpGet("authorize")]
18+
public IActionResult Authorize([Required] string state, [Required] string codeChallenge)
19+
{
20+
if (hikarinagiService.IsOAuth2Enable == false)
21+
return StatusCode(StatusCodes.Status503ServiceUnavailable, "Hikarinagi OAuth service is disabled.");
22+
try
23+
{
24+
return Redirect(hikarinagiService.GetAuthorizationUrl(state, codeChallenge));
25+
}
26+
catch (ArgumentException e)
27+
{
28+
return BadRequest(e.Message);
29+
}
30+
}
31+
32+
/// <summary>使用授权码和PKCE verifier换取Hikarinagi用户令牌</summary>
33+
/// <response code="200">成功,返回访问令牌和刷新令牌</response>
34+
/// <response code="400">授权码或PKCE verifier无效</response>
35+
/// <response code="502">无法连接至Hikarinagi ID</response>
36+
/// <response code="503">Hikarinagi OAuth服务没有启用</response>
37+
[HttpPost("oauth")]
38+
public async Task<ActionResult<HikarinagiToken>> OAuth([FromBody] HikarinagiOAuthCodeRequest request)
39+
{
40+
if (hikarinagiService.IsOAuth2Enable == false)
41+
return StatusCode(StatusCodes.Status503ServiceUnavailable, "Hikarinagi OAuth service is disabled.");
42+
try
43+
{
44+
return Ok(await hikarinagiService.GetUserTokenWithCodeAsync(request.Code, request.CodeVerifier));
45+
}
46+
catch (ArgumentException e)
47+
{
48+
return BadRequest(e.Message);
49+
}
50+
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.BadRequest)
51+
{
52+
logger.LogInformation(e, "Invalid Hikarinagi authorization code or PKCE verifier");
53+
return BadRequest(e.Message);
54+
}
55+
catch (Exception e)
56+
{
57+
logger.LogWarning(e, "Failed to exchange Hikarinagi authorization code");
58+
return StatusCode(StatusCodes.Status502BadGateway,
59+
"Hikarinagi OAuth service is temporarily unavailable.");
60+
}
61+
}
62+
63+
/// <summary>使用refresh token换取新的Hikarinagi用户令牌</summary>
64+
/// <remarks>Hikarinagi refresh token每次使用后都会轮换,客户端必须保存响应中的新值。</remarks>
65+
[HttpPost("refresh")]
66+
public async Task<ActionResult<HikarinagiToken>> Refresh([FromBody] HikarinagiRefreshTokenRequest request)
67+
{
68+
if (hikarinagiService.IsOAuth2Enable == false)
69+
return StatusCode(StatusCodes.Status503ServiceUnavailable, "Hikarinagi OAuth service is disabled.");
70+
try
71+
{
72+
return Ok(await hikarinagiService.GetUserTokenWithRefreshTokenAsync(request.RefreshToken));
73+
}
74+
catch (ArgumentException e)
75+
{
76+
return BadRequest(e.Message);
77+
}
78+
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.BadRequest)
79+
{
80+
logger.LogInformation(e, "Invalid Hikarinagi refresh token");
81+
return BadRequest(e.Message);
82+
}
83+
catch (Exception e)
84+
{
85+
logger.LogWarning(e, "Failed to refresh Hikarinagi user token");
86+
return StatusCode(StatusCodes.Status502BadGateway,
87+
"Hikarinagi OAuth service is temporarily unavailable.");
88+
}
89+
}
90+
}
91+
92+
public class HikarinagiOAuthCodeRequest
93+
{
94+
[Required]
95+
public string Code { get; set; } = string.Empty;
96+
97+
[Required]
98+
public string CodeVerifier { get; set; } = string.Empty;
99+
}
100+
101+
public class HikarinagiRefreshTokenRequest
102+
{
103+
[Required]
104+
public string RefreshToken { get; set; } = string.Empty;
105+
}

GalgameManager.Server/Controllers/ServerController.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ namespace GalgameManager.Server.Controllers;
77

88
[Route("[controller]")]
99
[ApiController]
10-
public class ServerController (IUserService userService, IBangumiService bgmService): ControllerBase
10+
public class ServerController(
11+
IUserService userService,
12+
IBangumiService bgmService,
13+
IHikarinagiService hikarinagiService): ControllerBase
1114
{
1215
/// <summary>获取服务器信息</summary>
1316
[HttpGet("info")]
@@ -19,6 +22,7 @@ public async Task<ActionResult<ServerInfoDto>> GetServerInfo()
1922
return Ok(new ServerInfoDto
2023
{
2124
BangumiOAuth2Enable = bgmService.IsOauth2Enable,
25+
HikarinagiOAuth2Enable = hikarinagiService.IsOAuth2Enable,
2226
DefaultLoginEnable = userService.IsDefaultLoginEnable,
2327
BangumiLoginEnable = bgmService.IsLoginEnable,
2428
GalgameStaffAvailable = true,

GalgameManager.Server/Models/Dtos/ServerInfoDto.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
public class ServerInfoDto
44
{
55
public required bool BangumiOAuth2Enable { get; set; }
6+
public required bool HikarinagiOAuth2Enable { get; set; }
67
public required bool DefaultLoginEnable { get; set; }
78
public required bool BangumiLoginEnable { get; set; }
89
/// <summary>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace GalgameManager.Server.Models;
2+
3+
public class HikarinagiToken
4+
{
5+
public required string AccessToken { get; set; }
6+
public required string RefreshToken { get; set; }
7+
public required string TokenType { get; set; }
8+
public required string Scope { get; set; }
9+
public long Expires { get; set; }
10+
}

GalgameManager.Server/Program.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,14 @@ private static bool CheckEnv(WebApplicationBuilder builder)
191191
result = Check("AppSettings:Bangumi:RedirectUri") && result;
192192
}
193193
result = CheckBoolValue("AppSettings:Hikarinagi:Enable", out var isHikarinagiEnable) && result;
194-
if (isHikarinagiEnable)
194+
result = CheckBoolValue("AppSettings:Hikarinagi:OAuth2Enable", out var isHikarinagiOAuth2Enable) && result;
195+
if (isHikarinagiEnable || isHikarinagiOAuth2Enable)
195196
{
196197
result = Check("AppSettings:Hikarinagi:ClientId") && result;
197198
result = Check("AppSettings:Hikarinagi:ClientSecret") && result;
198199
}
200+
if (isHikarinagiOAuth2Enable)
201+
result = Check("AppSettings:Hikarinagi:RedirectUri") && result;
199202
result = CheckBoolValue("AppSettings:User:Bangumi", out _) && result;
200203
result = CheckBoolValue("AppSettings:User:Default", out _) && result;
201204
result = CheckLongValue("AppSettings:User:OssSize", out _) && result;

GalgameManager.Server/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,11 @@ dotnet user-secrets set "Key" "Value"
7676
如果填写为`true`则必须设置AppId和AppSecret
7777
* `AppSettings:Bangumi:AppId` Bangumi第三方应用的AppId
7878
* `AppSettings:Bangumi:AppSecret` Bangumi第三方应用的AppSecret
79-
* `AppSettings:Hikarinagi:Enable` 是否作为Hikarinagi开放API的透传代理(客户端可经由本服务器使用Hikarinagi数据源),默认为`false`
80-
如果填写为`true`则必须设置ClientId和ClientSecret
79+
* `AppSettings:Hikarinagi:Enable` 是否作为Hikarinagi开放API的透传代理(客户端可经由本服务器使用Hikarinagi数据源),默认为`false`
80+
* `AppSettings:Hikarinagi:OAuth2Enable` 是否承担Hikarinagi用户OAuth认证,默认为`false`;启用后必须设置ClientId、ClientSecret和RedirectUri,并在Hikarinagi开发者控制台授予`status:write``offline_access`权限
8181
* `AppSettings:Hikarinagi:ClientId` Hikarinagi开发者平台创建的OAuth应用的ClientId
8282
* `AppSettings:Hikarinagi:ClientSecret` Hikarinagi开发者平台创建的OAuth应用的ClientSecret
83+
* `AppSettings:Hikarinagi:RedirectUri` Hikarinagi用户授权回调地址,客户端默认使用`potato-vn://oauth-hikarinagi`,必须与开发者控制台登记值完全一致
8384
* `AppSettings:User:Default` 是否允许用户以用户名密码注册与登录,默认为`true`
8485
* `AppSettings:User:Bangumi` 是否允许用户使用Bangumi账号注册与登录,默认为`false`
85-
* `AppSettings:User:OssSize` OSS上每位用户的存储空间大小,单位为byte,默认为`104857600`(100MB),此数值最大为2^63-1 (8388608TB)
86+
* `AppSettings:User:OssSize` OSS上每位用户的存储空间大小,单位为byte,默认为`104857600`(100MB),此数值最大为2^63-1 (8388608TB)

0 commit comments

Comments
 (0)