Add Steam OpenID authentication
Deploy Promiscuity Auth API / deploy (push) Failing after 20s
Deploy Promiscuity Character API / deploy (push) Failing after 19s
Deploy Promiscuity Crafting API / deploy (push) Failing after 20s
Deploy Promiscuity Inventory API / deploy (push) Failing after 19s
Deploy Promiscuity Locations API / deploy (push) Failing after 19s
Deploy Promiscuity Mail API / deploy (push) Failing after 20s
Deploy Promiscuity World API / deploy (push) Failing after 20s
k8s smoke test / test (push) Failing after 20s
Deploy Promiscuity Auth API / deploy (push) Failing after 20s
Deploy Promiscuity Character API / deploy (push) Failing after 19s
Deploy Promiscuity Crafting API / deploy (push) Failing after 20s
Deploy Promiscuity Inventory API / deploy (push) Failing after 19s
Deploy Promiscuity Locations API / deploy (push) Failing after 19s
Deploy Promiscuity Mail API / deploy (push) Failing after 20s
Deploy Promiscuity World API / deploy (push) Failing after 20s
k8s smoke test / test (push) Failing after 20s
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using AuthApi.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public JwtTokenService(IConfiguration cfg) => _cfg = cfg;
|
||||
|
||||
public (string Token, string Jti, DateTime ExpiresUtc) Generate(User user)
|
||||
{
|
||||
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key missing"));
|
||||
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
|
||||
var audience = _cfg["Jwt:Audience"] ?? issuer;
|
||||
var credentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
|
||||
var jti = Guid.NewGuid().ToString("N");
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Role, user.Role),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, jti)
|
||||
};
|
||||
var expiresUtc = DateTime.UtcNow.AddMinutes(15);
|
||||
var token = new JwtSecurityToken(issuer, audience, claims, expires: expiresUtc, signingCredentials: credentials);
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), jti, expiresUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AuthApi.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public class SteamAuthTransactionService
|
||||
{
|
||||
private readonly IMongoCollection<SteamAuthTransaction> _transactions;
|
||||
|
||||
public SteamAuthTransactionService(IConfiguration cfg)
|
||||
{
|
||||
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var database = new MongoClient(connectionString).GetDatabase(databaseName);
|
||||
_transactions = database.GetCollection<SteamAuthTransaction>("SteamAuthTransactions");
|
||||
var ttl = Builders<SteamAuthTransaction>.IndexKeys.Ascending(x => x.ExpiresUtc);
|
||||
_transactions.Indexes.CreateOne(new CreateIndexModel<SteamAuthTransaction>(ttl,
|
||||
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
|
||||
}
|
||||
|
||||
public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
|
||||
|
||||
public async Task<SteamAuthTransaction?> GetAsync(string id) =>
|
||||
await _transactions.Find(x => x.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<bool> MarkVerifiedAsync(string id, string steamId, string status, string? userId)
|
||||
{
|
||||
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
|
||||
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "pending") &
|
||||
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<SteamAuthTransaction>.Update
|
||||
.Set(x => x.SteamId, steamId)
|
||||
.Set(x => x.Status, status)
|
||||
.Set(x => x.UserId, userId);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkCompleteAsync(string id, string steamId, string userId)
|
||||
{
|
||||
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
|
||||
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "requires_account") &
|
||||
Builders<SteamAuthTransaction>.Filter.Eq(x => x.SteamId, steamId) &
|
||||
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<SteamAuthTransaction>.Update
|
||||
.Set(x => x.Status, "complete")
|
||||
.Set(x => x.UserId, userId);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public class SteamOpenIdService
|
||||
{
|
||||
public const string Endpoint = "https://steamcommunity.com/openid/login";
|
||||
private static readonly Regex ClaimedIdPattern = new(
|
||||
"^https?://steamcommunity\\.com/openid/id/(?<id>[0-9]{17})$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private readonly IHttpClientFactory _httpClients;
|
||||
|
||||
public SteamOpenIdService(IHttpClientFactory httpClients) => _httpClients = httpClients;
|
||||
|
||||
public static string BuildAuthenticationUrl(string realm, string returnUrl)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["openid.ns"] = "http://specs.openid.net/auth/2.0",
|
||||
["openid.mode"] = "checkid_setup",
|
||||
["openid.return_to"] = returnUrl,
|
||||
["openid.realm"] = realm,
|
||||
["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select"
|
||||
};
|
||||
return Endpoint + "?" + string.Join("&", parameters.Select(pair =>
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
||||
}
|
||||
|
||||
public async Task<string?> VerifyCallbackAsync(IQueryCollection query, string expectedReturnUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(query["openid.mode"], "id_res", StringComparison.Ordinal) ||
|
||||
!string.Equals(query["openid.op_endpoint"].ToString().TrimEnd('/'), Endpoint.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(query["openid.return_to"], expectedReturnUrl, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var claimedId = query["openid.claimed_id"].ToString();
|
||||
if (!string.Equals(claimedId, query["openid.identity"], StringComparison.Ordinal))
|
||||
return null;
|
||||
var match = ClaimedIdPattern.Match(claimedId);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var fields = query
|
||||
.Where(pair => pair.Key.StartsWith("openid.", StringComparison.Ordinal))
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
|
||||
fields["openid.mode"] = "check_authentication";
|
||||
var client = _httpClients.CreateClient(nameof(SteamOpenIdService));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
using var response = await client.PostAsync(Endpoint, new FormUrlEncodedContent(fields), cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
var validation = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return IsValidAuthenticationResponse(validation) ? match.Groups["id"].Value : null;
|
||||
}
|
||||
|
||||
internal static bool IsValidAuthenticationResponse(string response) =>
|
||||
response.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(line => string.Equals(line, "is_valid:true", StringComparison.Ordinal));
|
||||
|
||||
internal static string? ExtractSteamId(string claimedId)
|
||||
{
|
||||
var match = ClaimedIdPattern.Match(claimedId);
|
||||
return match.Success ? match.Groups["id"].Value : null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using AuthApi.Models;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
@@ -17,6 +19,12 @@ public class UserService
|
||||
|
||||
var keys = Builders<User>.IndexKeys.Ascending(u => u.Username);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<User>(keys, new CreateIndexOptions { Unique = true }));
|
||||
var steamKeys = Builders<User>.IndexKeys.Ascending(u => u.SteamId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<User>(steamKeys, new CreateIndexOptions<User>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string"))
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task<User?> GetByUsernameAsync(string username) =>
|
||||
@@ -28,11 +36,36 @@ public class UserService
|
||||
public async Task<User?> GetByEmailAsync(string email) =>
|
||||
await _col.Find(u => u.Email == email).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<List<User>> GetByLoginCandidatesAsync(string login)
|
||||
{
|
||||
var normalized = login.Trim();
|
||||
var usernameMatch = await GetByUsernameAsync(normalized);
|
||||
if (usernameMatch is not null)
|
||||
return [usernameMatch];
|
||||
var emailPattern = new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i");
|
||||
return await _col.Find(Builders<User>.Filter.Regex(u => u.Email, emailPattern)).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<User?> GetBySteamIdAsync(string steamId) =>
|
||||
await _col.Find(u => u.SteamId == steamId).FirstOrDefaultAsync();
|
||||
|
||||
public Task CreateAsync(User user) => _col.InsertOneAsync(user);
|
||||
|
||||
public Task UpdateAsync(User user) =>
|
||||
_col.ReplaceOneAsync(u => u.Id == user.Id, user);
|
||||
|
||||
public async Task<bool> TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry)
|
||||
{
|
||||
var filter = Builders<User>.Filter.Eq(u => u.Id, userId) &
|
||||
(Builders<User>.Filter.Eq(u => u.SteamId, null) | Builders<User>.Filter.Eq(u => u.SteamId, steamId));
|
||||
var update = Builders<User>.Update
|
||||
.Set(u => u.SteamId, steamId)
|
||||
.Set(u => u.RefreshToken, refreshToken)
|
||||
.Set(u => u.RefreshTokenExpiry, refreshExpiry);
|
||||
var result = await _col.UpdateOneAsync(filter, update);
|
||||
return result.MatchedCount == 1;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id);
|
||||
|
||||
public Task<List<User>> GetAllAsync() =>
|
||||
|
||||
Reference in New Issue
Block a user