Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Spec.md

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions src/MesApp.Api/AccessTokenValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Security.Claims;
using MesApp.Core.Constants;
using MesApp.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;

namespace MesApp.Api;

/// <summary>
/// アクセストークンの即時失効(Spec.md 7.4)。署名・有効期限の検証が通ったあとに、
/// ユーザーが有効か、トークンのセキュリティスタンプがDBの値と一致するかを照合する。
/// </summary>
/// <remarks>
/// 署名と有効期限だけでは、無効化・ロール変更・パスワードリセットをしても発行済みのトークンが
/// 有効期限(既定60分)まで使えてしまう。リクエストごとに主キー検索が1回増えるが、
/// キャッシュは挟まない(挟むとキャッシュが切れるまで無効化が効かない)。
/// </remarks>
public static class AccessTokenValidation
{
/// <summary>JwtBearer の OnTokenValidated に渡す</summary>
public static async Task ValidateAsync(TokenValidatedContext context)
{
var principal = context.Principal;
var userId = principal?.FindFirstValue(ClaimTypes.NameIdentifier);
var stamp = principal?.FindFirstValue(MesClaimTypes.SecurityStamp);
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(stamp))
{
// スタンプを載せる前に発行されたトークンも弾く(クライアントはリフレッシュで取り直す)
context.Fail("トークンにユーザーまたはセキュリティスタンプがありません。");
return;
}

var db = context.HttpContext.RequestServices.GetRequiredService<MesAppDbContext>();
var user = await db.Users.AsNoTracking()
.Where(u => u.Id == userId)
.Select(u => new { u.IsActive, u.SecurityStamp })
.FirstOrDefaultAsync(context.HttpContext.RequestAborted);
if (user is null || !user.IsActive || user.SecurityStamp != stamp)
{
context.Fail("ユーザーが無効化されたか、権限・パスワードが変更されたためトークンは失効しています。");
}
}
}
6 changes: 6 additions & 0 deletions src/MesApp.Api/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ public async Task<ActionResult<UserSummaryResponse>> Update(string id, UpdateUse
await userManager.RemoveFromRolesAsync(user, currentRoles.Except(request.Roles));
await userManager.AddToRolesAsync(user, request.Roles.Except(currentRoles));

// 無効化・ロール変更は発行済みのアクセストークンにも即時に効かせる(Spec.md 7.4)。
// ロール変更だけならリフレッシュトークンは生きているので、クライアントは新しいロールで取り直す
if (deactivated || !currentRoles.ToHashSet().SetEquals(request.Roles))
{
await userManager.UpdateSecurityStampAsync(user);
}
if (deactivated)
{
// 無効化されたユーザーのセッションを止める(Spec.md 7.4)
Expand Down
7 changes: 6 additions & 1 deletion src/MesApp.Api/MesAppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ public static WebApplication Build(string[] args)
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<MesAppDbContext>()
.AddSignInManager();
.AddSignInManager()
// 管理者によるパスワードリセット(ユーザーCSVの InitialPassword・初期管理者の再生成を含む)は
// リセット用トークンを発行してから使う。プロバイダが無いと発行で例外になる
.AddDefaultTokenProviders();

// JWT認証(アクセストークン。リフレッシュはRefreshTokenService+HttpOnly Cookie)
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
Expand Down Expand Up @@ -128,6 +131,8 @@ public static WebApplication Build(string[] args)
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
};
// 無効化・ロール変更・パスワード変更を発行済みトークンにも即時に効かせる(Spec.md 7.4)
options.Events = new JwtBearerEvents { OnTokenValidated = AccessTokenValidation.ValidateAsync };
});

builder.Services.AddAuthorization();
Expand Down
4 changes: 2 additions & 2 deletions src/MesApp.Api/MustChangePasswordFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ namespace MesApp.Api;
/// リクエストごとのDB参照はしない。
/// </para>
/// <para>
/// 管理者によるパスワードリセット直後など、既に発行済みのトークンにはこのクレームが付かない。
/// 反映は次回のトークン発行時(ロール変更と同じ扱い)。
/// 管理者によるパスワードリセットでは、セキュリティスタンプが変わって発行済みのトークンが失効する
/// (<see cref="AccessTokenValidation"/>)。再ログインで発行し直したトークンにこのクレームが付く。
/// </para>
/// </summary>
public sealed class MustChangePasswordFilter : IAuthorizationFilter
Expand Down
30 changes: 28 additions & 2 deletions src/MesApp.Api/Services/CsvFile.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;

namespace MesApp.Api.Services;
Expand Down Expand Up @@ -85,7 +86,7 @@ public static List<CsvRecord> Parse(string text)

void EndRecord()
{
fields.Add(field.ToString());
fields.Add(Unprotect(field.ToString()));
field.Clear();
if (fields.Count > 1 || fields[0].Trim().Length > 0)
{
Expand Down Expand Up @@ -128,7 +129,7 @@ void EndRecord()
inQuotes = true;
break;
case ',':
fields.Add(field.ToString());
fields.Add(Unprotect(field.ToString()));
field.Clear();
break;
case '\r':
Expand Down Expand Up @@ -172,9 +173,34 @@ private static string Escape(string? value)
{
return string.Empty;
}
if (IsFormulaLike(value))
{
value = FormulaGuard + value;
}
var needsQuotes = value.AsSpan().IndexOfAny(',', '"', '\n') >= 0
|| value.Contains('\r')
|| value[0] == ' ' || value[^1] == ' ';
return needsQuotes ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}

/// <summary>
/// 数式とみなされる値の前に付ける印。Excel等の表計算ソフトで開いたとき、利用者が入力した品目名や備考が
/// 数式(<c>=HYPERLINK(...)</c> やDDE)として実行されないようにする(CSVインジェクション対策。Spec.md 3.8)
/// </summary>
private const char FormulaGuard = '\'';

/// <summary>
/// 表計算ソフトが数式として解釈する先頭文字か。<c>+</c>・<c>-</c> は数値(<c>-5</c>・<c>+1.5</c>)なら数式にならないので除く
/// (数量・座標の負値に印が付くと、表計算での集計や取り込み直しで数値として扱えなくなる)
/// </summary>
private static bool IsFormulaLike(string value) => value[0] switch
{
'=' or '@' or '\t' or '\r' => true,
'+' or '-' => !decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _),
_ => false,
};

/// <summary>出力時に付けた印を外す(出力したCSVをそのまま取り込み直しても元の値に戻るようにする)</summary>
private static string Unprotect(string value) =>
value.Length >= 2 && value[0] == FormulaGuard && IsFormulaLike(value[1..]) ? value[1..] : value;
}
2 changes: 2 additions & 0 deletions src/MesApp.Api/Services/JwtTokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public class JwtTokenService(SigningKeyProvider keyProvider, IOptions<JwtOptions
new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new(MesClaimTypes.DisplayName, user.DisplayName),
// 検証時にDBの値と照合する(AccessTokenValidation)
new(MesClaimTypes.SecurityStamp, user.SecurityStamp ?? string.Empty),
};
// 初期パスワードのままのトークンには印を付ける(MustChangePasswordFilterが業務APIを止める)
if (user.MustChangePassword)
Expand Down
8 changes: 8 additions & 0 deletions src/MesApp.Api/Services/MasterCsvService.Import.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1543,12 +1543,20 @@ private async Task ImportUsersAsync(
}
await userManager.UpdateAsync(user);

var rolesChanged = false;
if (roles is not null)
{
var currentRoles = await userManager.GetRolesAsync(user);
rolesChanged = !currentRoles.ToHashSet().SetEquals(roles);
await userManager.RemoveFromRolesAsync(user, currentRoles.Except(roles));
await userManager.AddToRolesAsync(user, roles.Except(currentRoles));
}
// 単票APIと同じく、無効化・ロール変更は発行済みのアクセストークンにも即時に効かせる(Spec.md 7.4。
// パスワードの指定は ResetPasswordAsync がスタンプを更新する)
if (deactivated || rolesChanged)
{
await userManager.UpdateSecurityStampAsync(user);
}
if (password is not null)
{
// CSVでパスワードを指定した場合はリセット扱い(次回ログイン時に変更を強制)
Expand Down
6 changes: 6 additions & 0 deletions src/MesApp.Core/Constants/MesClaimTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ public static class MesClaimTypes
/// (初期パスワードのまま業務APIを叩けないようにするため)
/// </summary>
public const string MustChangePassword = "must_change_password";

/// <summary>
/// 発行時点のセキュリティスタンプ(Identity の SecurityStamp)。無効化・ロール変更・パスワード変更で
/// DB側の値が変わると、発行済みのアクセストークンは有効期限を待たずに使えなくなる
/// </summary>
public const string SecurityStamp = "security_stamp";
}
56 changes: 56 additions & 0 deletions tests/MesApp.Api.Tests/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,62 @@ public async Task 信頼するプロキシが未設定なら転送ヘッダを
/// プロキシ(remoteAddress)から、HTTPSで受けた ClientAddress のリクエストを転送された形でログインする。
/// HttpClient経由では接続元IPを指定できないため、TestServerに直接HttpContextを組み立てて渡す
/// </summary>
[Fact]
public async Task ロール変更で発行済みのアクセストークンは失効しリフレッシュで新しいロールになる()
{
using var factory = new ApiFactory();
using var admin = await TestAuth.CreateAdminClientAsync(factory);
using var worker = await TestAuth.CreateUserClientAsync(factory, admin, "worker1", "Passw0rd123", MesRoles.Operator);
Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/locations")).StatusCode);

var id = await UserIdAsync(admin, "worker1");
(await admin.PutAsJsonAsync($"/api/users/{id}",
new UpdateUserRequest("worker1", [MesRoles.ProductionManager], IsActive: true))).EnsureSuccessStatusCode();

// 古いロールのトークンは有効期限を待たずに使えなくなる
Assert.Equal(HttpStatusCode.Unauthorized, (await worker.GetAsync("/api/locations")).StatusCode);

// リフレッシュトークンは生きているので、取り直せば新しいロールで操作を続けられる
var refresh = await worker.PostAsync("/api/auth/refresh", null);
Assert.Equal(HttpStatusCode.OK, refresh.StatusCode);
var token = (await refresh.Content.ReadFromJsonAsync<TokenResponse>())!;
Assert.Equal([MesRoles.ProductionManager], token.User.Roles);
worker.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/locations")).StatusCode);

// 表示名だけの更新ではトークンを失効させない
(await admin.PutAsJsonAsync($"/api/users/{id}",
new UpdateUserRequest("作業者1", [MesRoles.ProductionManager], IsActive: true))).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/locations")).StatusCode);
}

[Fact]
public async Task 無効化とパスワードリセットで発行済みのアクセストークンは即時に失効する()
{
using var factory = new ApiFactory();
using var admin = await TestAuth.CreateAdminClientAsync(factory);
using var retired = await TestAuth.CreateUserClientAsync(factory, admin, "retired1", "Passw0rd123", MesRoles.Operator);
using var reset = await TestAuth.CreateUserClientAsync(factory, admin, "reset1", "Passw0rd123", MesRoles.Operator);

// 無効化:アクセストークンもリフレッシュも使えない
(await admin.PutAsJsonAsync($"/api/users/{await UserIdAsync(admin, "retired1")}",
new UpdateUserRequest("retired1", [MesRoles.Operator], IsActive: false))).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Unauthorized, (await retired.GetAsync("/api/locations")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await retired.PostAsync("/api/auth/refresh", null)).StatusCode);

// 管理者によるパスワードリセット
(await admin.PostAsJsonAsync($"/api/users/{await UserIdAsync(admin, "reset1")}/reset-password",
new ResetPasswordRequest("NewPassw0rd456"))).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Unauthorized, (await reset.GetAsync("/api/locations")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await reset.PostAsync("/api/auth/refresh", null)).StatusCode);

// 他のユーザー(管理者)のトークンには影響しない
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/api/locations")).StatusCode);
}

private static async Task<string> UserIdAsync(HttpClient admin, string userName) =>
(await admin.GetFromJsonAsync<List<UserSummaryResponse>>("/api/users"))!.Single(u => u.UserName == userName).Id;

private static Task<HttpContext> LoginViaProxyAsync(ApiFactory factory, string remoteAddress) =>
factory.Server.SendAsync(context =>
{
Expand Down
33 changes: 33 additions & 0 deletions tests/MesApp.Api.Tests/MasterCsvTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,39 @@ public async Task 不良理由マスタをCSVで入出力できる()
Assert.Contains("DF-01,寸法外れ(外径),Equipment,true", exported);
}

[Fact]
public async Task CSV出力は数式とみなされる値に印を付け_取り込み直すと元の値に戻る()
{
using var factory = new ApiFactory();
using var client = await TestAuth.CreateAdminClientAsync(factory);
var result = await ImportAsync(client, "defect-reasons", """
Code,Name,Category,IsActive
DF-01,"=HYPERLINK(""http://example.invalid"",""x"")",Process,true
DF-02,@SUM(A1),Process,true
DF-03,-A1+1,Process,true
DF-04,-5,Process,true
DF-05,寸法外れ,Process,true
""");
Assert.Empty(result.Errors);

// Excel等で開いたときに数式として実行されないよう ' を前に付ける。負の数・通常の文字列には付けない
var exported = Encoding.UTF8.GetString(
await (await client.GetAsync("/api/masters/csv/defect-reasons")).Content.ReadAsByteArrayAsync());
Assert.Contains("DF-01,\"'=HYPERLINK(\"\"http://example.invalid\"\",\"\"x\"\")\",Process", exported);
Assert.Contains("DF-02,'@SUM(A1),Process", exported);
Assert.Contains("DF-03,'-A1+1,Process", exported);
Assert.Contains("DF-04,-5,Process", exported);
Assert.Contains("DF-05,寸法外れ,Process", exported);

// 出力したCSVをそのまま取り込み直しても、印は外れて元の値のまま
var again = await ImportAsync(client, "defect-reasons", exported.TrimStart(''));
Assert.Empty(again.Errors);
var reasons = (await client.GetFromJsonAsync<List<DefectReasonResponse>>("/api/defect-reasons"))!;
Assert.Equal("=HYPERLINK(\"http://example.invalid\",\"x\")", reasons.Single(r => r.Code == "DF-01").Name);
Assert.Equal("@SUM(A1)", reasons.Single(r => r.Code == "DF-02").Name);
Assert.Equal("-A1+1", reasons.Single(r => r.Code == "DF-03").Name);
}

[Fact]
public async Task ユーザーCSVで最後のシステム管理者を降格できない()
{
Expand Down
Loading