OAuth (Google)

This commit is contained in:
Kieran 2022-09-08 13:38:32 +01:00
parent 3e08056f0b
commit 1532d43189
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
53 changed files with 246 additions and 163 deletions

View File

@ -12,15 +12,17 @@ public class InfoController : Controller
private readonly VoidSettings _settings;
private readonly ITimeSeriesStatsReporter _timeSeriesStats;
private readonly IEnumerable<string?> _fileStores;
private readonly IEnumerable<string> _oAuthProviders;
public InfoController(IStatsReporter statsReporter, IFileMetadataStore fileMetadata, VoidSettings settings,
ITimeSeriesStatsReporter stats, IEnumerable<IFileStore> fileStores)
ITimeSeriesStatsReporter stats, IEnumerable<IFileStore> fileStores, IEnumerable<IOAuthProvider> oAuthProviders)
{
_statsReporter = statsReporter;
_fileMetadata = fileMetadata;
_settings = settings;
_timeSeriesStats = stats;
_fileStores = fileStores.Select(a => a.Key);
_oAuthProviders = oAuthProviders.Select(a => a.Id);
}
/// <summary>
@ -43,7 +45,8 @@ public class InfoController : Controller
CaptchaSiteKey = _settings.CaptchaSettings?.SiteKey,
TimeSeriesMetrics = await _timeSeriesStats.GetBandwidth(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow),
FileStores = _fileStores,
UploadSegmentSize = _settings.UploadSegmentSize
UploadSegmentSize = _settings.UploadSegmentSize,
OAuthProviders = _oAuthProviders
};
}
@ -57,5 +60,6 @@ public class InfoController : Controller
public IEnumerable<BandwidthPoint> TimeSeriesMetrics { get; init; }
public IEnumerable<string?> FileStores { get; init; }
public ulong? UploadSegmentSize { get; init; }
public IEnumerable<string> OAuthProviders { get; init; }
}
}

View File

@ -253,4 +253,7 @@ public static class Extensions
public static bool HasDiscord(this VoidSettings settings)
=> settings.Discord != null;
public static bool HasGoogle(this VoidSettings settings)
=> settings.Google != null;
}

View File

@ -104,7 +104,12 @@ namespace VoidCat.Model
/// <summary>
/// Discord application settings
/// </summary>
public DiscordSettings? Discord { get; init; }
public OAuthDetails? Discord { get; init; }
/// <summary>
/// Google application settings
/// </summary>
public OAuthDetails? Google { get; init; }
}
public sealed class TorSettings
@ -180,7 +185,7 @@ namespace VoidCat.Model
public string? Domain { get; init; }
}
public sealed class DiscordSettings
public sealed class OAuthDetails
{
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }

View File

@ -5,17 +5,14 @@ using VoidCat.Model.User;
namespace VoidCat.Services.Users.Auth;
/// <inheritdoc />
public class DiscordOAuthProvider : GenericOAuth2Service<DiscordAccessToken>
public class DiscordOAuthProvider : GenericOAuth2Service
{
private readonly HttpClient _client;
private readonly DiscordSettings _discord;
private readonly Uri _site;
public DiscordOAuthProvider(HttpClient client, VoidSettings settings) : base(client)
public DiscordOAuthProvider(HttpClient client, VoidSettings settings) : base(client, settings)
{
_client = client;
_discord = settings.Discord!;
_site = settings.SiteUrl;
Details = settings.Discord!;
}
/// <inheritdoc />
@ -48,61 +45,17 @@ public class DiscordOAuthProvider : GenericOAuth2Service<DiscordAccessToken>
return default;
}
/// <inheritdoc />
protected override Dictionary<string, string> BuildAuthorizeQuery()
=> new()
{
{"response_type", "code"},
{"client_id", _discord.ClientId!},
{"scope", "email identify"},
{"prompt", "none"},
{"redirect_uri", new Uri(_site, $"/auth/{Id}/token").ToString()}
};
protected override Dictionary<string, string> BuildTokenQuery(string code)
=> new()
{
{"client_id", _discord.ClientId!},
{"client_secret", _discord.ClientSecret!},
{"grant_type", "authorization_code"},
{"code", code},
{"redirect_uri", new Uri(_site, $"/auth/{Id}/token").ToString()}
};
/// <inheritdoc />
protected override UserAuthToken TransformDto(DiscordAccessToken dto)
{
return new()
{
Id = Guid.NewGuid(),
Provider = Id,
AccessToken = dto.AccessToken,
Expires = DateTime.UtcNow.AddSeconds(dto.ExpiresIn),
TokenType = dto.TokenType,
RefreshToken = dto.RefreshToken,
Scope = dto.Scope
};
}
/// <inheritdoc />
protected override Uri AuthorizeEndpoint => new("https://discord.com/oauth2/authorize");
/// <inheritdoc />
protected override Uri TokenEndpoint => new("https://discord.com/api/oauth2/token");
}
public class DiscordAccessToken
{
[JsonProperty("access_token")] public string AccessToken { get; init; }
/// <inheritdoc />
protected override OAuthDetails Details { get; }
[JsonProperty("expires_in")] public int ExpiresIn { get; init; }
[JsonProperty("token_type")] public string TokenType { get; init; }
[JsonProperty("refresh_token")] public string RefreshToken { get; init; }
[JsonProperty("scope")] public string Scope { get; init; }
}
/// <inheritdoc />
protected override string[] Scopes => new[] {"email", "identify"};
internal class DiscordUser
{
@ -116,3 +69,4 @@ internal class DiscordUser
[JsonProperty("email")] public string? Email { get; init; }
}
}

View File

@ -1,4 +1,5 @@
using Newtonsoft.Json;
using VoidCat.Model;
using VoidCat.Model.User;
using VoidCat.Services.Abstractions;
@ -7,12 +8,14 @@ namespace VoidCat.Services.Users.Auth;
/// <summary>
/// Generic base class for OAuth2 code grant flow
/// </summary>
public abstract class GenericOAuth2Service<TDto> : IOAuthProvider
public abstract class GenericOAuth2Service : IOAuthProvider
{
private readonly Uri _uri;
private readonly HttpClient _client;
protected GenericOAuth2Service(HttpClient client)
protected GenericOAuth2Service(HttpClient client, VoidSettings settings)
{
_uri = settings.SiteUrl;
_client = client;
}
@ -40,7 +43,8 @@ public abstract class GenericOAuth2Service<TDto> : IOAuthProvider
{
throw new InvalidOperationException($"Failed to get token from provider: {Id}, response: {json}");
}
var dto = JsonConvert.DeserializeObject<TDto>(json);
var dto = JsonConvert.DeserializeObject<OAuthAccessToken>(json);
return TransformDto(dto!);
}
@ -51,20 +55,29 @@ public abstract class GenericOAuth2Service<TDto> : IOAuthProvider
/// Build query args for authorize
/// </summary>
/// <returns></returns>
protected abstract Dictionary<string, string> BuildAuthorizeQuery();
protected virtual Dictionary<string, string> BuildAuthorizeQuery()
=> new()
{
{"response_type", "code"},
{"client_id", Details.ClientId!},
{"scope", string.Join(" ", Scopes)},
{"prompt", "none"},
{"redirect_uri", new Uri(_uri, $"/auth/{Id}/token").ToString()}
};
/// <summary>
/// Build query args for token generation
/// </summary>
/// <returns></returns>
protected abstract Dictionary<string, string> BuildTokenQuery(string code);
/// <summary>
/// Transform DTO to <see cref="UserAuthToken"/>
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
protected abstract UserAuthToken TransformDto(TDto dto);
protected virtual Dictionary<string, string> BuildTokenQuery(string code)
=> new()
{
{"client_id", Details.ClientId!},
{"client_secret", Details.ClientSecret!},
{"grant_type", "authorization_code"},
{"code", code},
{"redirect_uri", new Uri(_uri, $"/auth/{Id}/token").ToString()}
};
/// <summary>
/// Authorize url for this service
@ -75,4 +88,46 @@ public abstract class GenericOAuth2Service<TDto> : IOAuthProvider
/// Generate token url for this service
/// </summary>
protected abstract Uri TokenEndpoint { get; }
/// <summary>
/// OAuth client details
/// </summary>
protected abstract OAuthDetails Details { get; }
/// <summary>
/// OAuth scopes
/// </summary>
protected abstract string[] Scopes { get; }
/// <summary>
/// Transform DTO to <see cref="UserAuthToken"/>
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
protected virtual UserAuthToken TransformDto(OAuthAccessToken dto)
{
return new()
{
Id = Guid.NewGuid(),
Provider = Id,
AccessToken = dto.AccessToken,
Expires = DateTime.UtcNow.AddSeconds(dto.ExpiresIn),
TokenType = dto.TokenType,
RefreshToken = dto.RefreshToken,
Scope = dto.Scope
};
}
protected class OAuthAccessToken
{
[JsonProperty("access_token")] public string AccessToken { get; init; }
[JsonProperty("expires_in")] public int ExpiresIn { get; init; }
[JsonProperty("token_type")] public string TokenType { get; init; }
[JsonProperty("refresh_token")] public string RefreshToken { get; init; }
[JsonProperty("scope")] public string Scope { get; init; }
}
}

View File

@ -0,0 +1,51 @@
using System.IdentityModel.Tokens.Jwt;
using VoidCat.Model;
using VoidCat.Model.User;
namespace VoidCat.Services.Users.Auth;
public class GoogleOAuthProvider : GenericOAuth2Service
{
private readonly HttpClient _client;
public GoogleOAuthProvider(HttpClient client, VoidSettings settings) : base(client, settings)
{
_client = client;
Details = settings.Google!;
}
/// <inheritdoc />
public override string Id => "google";
/// <inheritdoc />
public override ValueTask<InternalUser?> GetUserDetails(UserAuthToken token)
{
var jwt = JwtPayload.Base64UrlDeserialize(token.AccessToken);
return ValueTask.FromResult(new InternalUser()
{
Id = Guid.NewGuid(),
Created = DateTimeOffset.UtcNow,
LastLogin = DateTimeOffset.UtcNow,
AuthType = AuthType.OAuth2,
Email = jwt.Jti,
DisplayName = jwt.Acr
})!;
}
/// <inheritdoc />
protected override Uri AuthorizeEndpoint => new("https://accounts.google.com/o/oauth2/v2/auth");
/// <inheritdoc />
protected override Uri TokenEndpoint => new("https://oauth2.googleapis.com/token");
/// <inheritdoc />
protected override OAuthDetails Details { get; }
/// <inheritdoc />
protected override string[] Scopes => new[]
{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"};
}
public sealed class GoogleUserAccount
{
}

View File

@ -16,6 +16,11 @@ public static class UsersStartup
services.AddTransient<IOAuthProvider, DiscordOAuthProvider>();
}
if (settings.HasGoogle())
{
services.AddTransient<IOAuthProvider, GoogleOAuthProvider>();
}
if (settings.HasPostgres())
{
services.AddTransient<IUserStore, PostgresUserStore>();

View File

@ -1,12 +1,13 @@
import "./Admin.css";
import {useSelector} from "react-redux";
import {FileList} from "../FileList";
import {UserList} from "./UserList";
import {Navigate} from "react-router-dom";
import {useApi} from "../Api";
import {VoidButton} from "../VoidButton";
import {useState} from "react";
import VoidModal from "../VoidModal";
import {useSelector} from "react-redux";
import {Navigate} from "react-router-dom";
import {FileList} from "../Components/Shared/FileList";
import {UserList} from "./UserList";
import {useApi} from "../Components/Shared/Api";
import {VoidButton} from "../Components/Shared/VoidButton";
import VoidModal from "../Components/Shared/VoidModal";
import EditUser from "./EditUser";
export function Admin() {

View File

@ -1,7 +1,7 @@
import {VoidButton} from "../VoidButton";
import {useState} from "react";
import {useSelector} from "react-redux";
import {useApi} from "../Api";
import {useApi} from "../Components/Shared/Api";
import {VoidButton} from "../Components/Shared/VoidButton";
export default function EditUser(props) {
const user = props.user;

View File

@ -1,10 +1,10 @@
import {useDispatch} from "react-redux";
import {useEffect, useState} from "react";
import {PagedSortBy, PageSortOrder} from "../Const";
import {useApi} from "../Api";
import {logout} from "../LoginState";
import {PageSelector} from "../PageSelector";
import moment from "moment";
import {PagedSortBy, PageSortOrder} from "../Components/Shared/Const";
import {useApi} from "../Components/Shared/Api";
import {logout} from "../LoginState";
import {PageSelector} from "../Components/Shared/PageSelector";
export function UserList(props) {
const {AdminApi} = useApi();

View File

@ -1,12 +1,12 @@
import {BrowserRouter, Routes, Route} from "react-router-dom";
import {Provider} from "react-redux";
import store from "./Store";
import {FilePreview} from "./FilePreview";
import {HomePage} from "./HomePage";
import {FilePreview} from "./Pages/FilePreview";
import {HomePage} from "./Pages/HomePage";
import {Admin} from "./Admin/Admin";
import {UserLogin} from "./UserLogin";
import {Profile} from "./Profile";
import {Header} from "./Header";
import {UserLogin} from "./Pages/UserLogin";
import {Profile} from "./Pages/Profile";
import {Header} from "./Components/Shared/Header";
import './App.css';

View File

@ -1,13 +1,13 @@
import "./FileEdit.css";
import {useState} from "react";
import {useSelector} from "react-redux";
import moment from "moment";
import {StrikePaymentConfig} from "./StrikePaymentConfig";
import {NoPaymentConfig} from "./NoPaymentConfig";
import {useApi} from "./Api";
import "./FileEdit.css";
import {useSelector} from "react-redux";
import {VoidButton} from "./VoidButton";
import moment from "moment";
import {PaymentServices} from "./Const";
import {useApi} from "../Shared/Api";
import {VoidButton} from "../Shared/VoidButton";
import {PaymentServices} from "../Shared/Const";
export function FileEdit(props) {
const {Api} = useApi();

View File

@ -1,6 +1,6 @@
import FeatherIcon from "feather-icons-react";
import {useState} from "react";
import {VoidButton} from "./VoidButton";
import {VoidButton} from "../Shared/VoidButton";
export function NoPaymentConfig(props) {
const [saveStatus, setSaveStatus] = useState();

View File

@ -1,7 +1,7 @@
import {useState} from "react";
import FeatherIcon from "feather-icons-react";
import {PaymentCurrencies} from "./Const";
import {VoidButton} from "./VoidButton";
import {PaymentCurrencies} from "../Shared/Const";
import {VoidButton} from "../Shared/VoidButton";
export function StrikePaymentConfig(props) {
const file = props.file;

View File

@ -1,10 +1,10 @@
import "./FilePayment.css";
import {FormatCurrency} from "./Util";
import {PaymentServices} from "./Const";
import {useState} from "react";
import {FormatCurrency} from "../Shared/Util";
import {PaymentServices} from "../Shared/Const";
import {LightningPayment} from "./LightningPayment";
import {useApi} from "./Api";
import {VoidButton} from "./VoidButton";
import {useApi} from "../Shared/Api";
import {VoidButton} from "../Shared/VoidButton";
export function FilePayment(props) {
const {Api} = useApi();

View File

@ -1,9 +1,9 @@
import QRCode from "qrcode.react";
import {useEffect} from "react";
import {Countdown} from "./Countdown";
import {PaymentOrderState} from "./Const";
import {useApi} from "./Api";
import {Countdown} from "../Shared/Countdown";
import {PaymentOrderState} from "../Shared/Const";
import {useApi} from "../Shared/Api";
export function LightningPayment(props) {
const {Api} = useApi();

View File

@ -1,8 +1,7 @@
import "./Dropzone.css";
import {Fragment, useEffect, useState} from "react";
import {FileUpload} from "./FileUpload";
import "./Dropzone.css";
export function Dropzone(props) {
let [files, setFiles] = useState([]);

View File

@ -1,11 +1,11 @@
import {useEffect, useState} from "react";
import {ConstName, FormatBytes} from "./Util";
import {RateCalculator} from "./RateCalculator";
import * as CryptoJS from 'crypto-js';
import "./FileUpload.css";
import {useEffect, useState} from "react";
import * as CryptoJS from 'crypto-js';
import {useSelector} from "react-redux";
import {ApiHost} from "./Const";
import {ConstName, FormatBytes} from "../Shared/Util";
import {RateCalculator} from "../Shared/RateCalculator";
import {ApiHost} from "../Shared/Const";
const UploadState = {
NotStarted: 0,

View File

@ -1,5 +1,5 @@
import "./FooterLinks.css"
import StrikeLogo from "./image/strike.png";
import StrikeLogo from "../../image/strike.png";
import {useSelector} from "react-redux";
export function FooterLinks() {

View File

@ -1,8 +1,7 @@
import {Fragment} from "react";
import "./GlobalStats.css";
import {Fragment} from "react";
import FeatherIcon from "feather-icons-react";
import {FormatBytes} from "./Util";
import "./GlobalStats.css";
import {FormatBytes} from "../Shared/Util";
import moment from "moment";
import {useSelector} from "react-redux";

View File

@ -1,5 +1,5 @@
import {Bar, BarChart, Tooltip, XAxis} from "recharts";
import {FormatBytes} from "./Util";
import {FormatBytes} from "../Shared/Util";
import moment from "moment";
export function MetricsGraph(props) {

View File

@ -1,8 +1,9 @@
import {useApi} from "./Api";
import {useEffect, useState} from "react";
import {VoidButton} from "./VoidButton";
import moment from "moment";
import VoidModal from "./VoidModal";
import {useApi} from "../Shared/Api";
import {VoidButton} from "../Shared/VoidButton";
import VoidModal from "../Shared/VoidModal";
export default function ApiKeyList() {
const {Api} = useApi();

View File

@ -1,10 +1,11 @@
import moment from "moment";
import {Link} from "react-router-dom";
import {useDispatch} from "react-redux";
import {useEffect, useState} from "react";
import {FormatBytes} from "./Util";
import {logout} from "./LoginState";
import {Link} from "react-router-dom";
import moment from "moment";
import {PagedSortBy, PageSortOrder} from "./Const";
import {logout} from "../../LoginState";
import {FormatBytes} from "./Util";
import {PageSelector} from "./PageSelector";
export function FileList(props) {

View File

@ -3,9 +3,9 @@ import {Link} from "react-router-dom";
import {useDispatch, useSelector} from "react-redux";
import {InlineProfile} from "./InlineProfile";
import {useApi} from "./Api";
import {logout, setAuth, setProfile} from "./LoginState";
import {logout, setAuth, setProfile} from "../../LoginState";
import {useEffect} from "react";
import {setInfo} from "./SiteInfoStore";
import {setInfo} from "../../SiteInfoStore";
export function Header() {
const dispatch = useDispatch();

View File

@ -1,6 +1,6 @@
import {useState} from "react";
import {useDispatch, useSelector} from "react-redux";
import {setAuth} from "./LoginState";
import {setAuth} from "../../LoginState";
import {useApi} from "./Api";
import "./Login.css";
import HCaptcha from "@hcaptcha/react-hcaptcha";
@ -13,6 +13,7 @@ export function Login() {
const [error, setError] = useState();
const [captchaResponse, setCaptchaResponse] = useState();
const captchaKey = useSelector(state => state.info.info?.captchaSiteKey);
const oAuthProviders = useSelector(state => state.info.info?.oAuthProviders);
const dispatch = useDispatch();
async function login(fnLogin) {
@ -43,7 +44,10 @@ export function Login() {
<VoidButton onClick={() => login(Api.login)}>Login</VoidButton>
<VoidButton onClick={() => login(Api.register)}>Register</VoidButton>
<br/>
<VoidButton onClick={() => window.location.href = `/auth/discord`}>Login with Discord</VoidButton>
{oAuthProviders ?
oAuthProviders.map(a => <VoidButton key={a} onClick={() => window.location.href = `/auth/${a}`}>
Login with {a}
</VoidButton>) : null}
{error ? <div className="error-msg">{error}</div> : null}
</div>
);

View File

@ -1,15 +1,15 @@
import "./FilePreview.css";
import {Fragment, useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import {TextPreview} from "./TextPreview";
import {TextPreview} from "../Components/FilePreview/TextPreview";
import FeatherIcon from "feather-icons-react";
import "./FilePreview.css";
import {FileEdit} from "./FileEdit";
import {FilePayment} from "./FilePayment";
import {useApi} from "./Api";
import {FileEdit} from "../Components/FileEdit/FileEdit";
import {FilePayment} from "../Components/FilePreview/FilePayment";
import {useApi} from "../Components/Shared/Api";
import {Helmet} from "react-helmet";
import {FormatBytes} from "./Util";
import {ApiHost} from "./Const";
import {InlineProfile} from "./InlineProfile";
import {FormatBytes} from "../Components/Shared/Util";
import {ApiHost} from "../Components/Shared/Const";
import {InlineProfile} from "../Components/Shared/InlineProfile";
export function FilePreview() {
const {Api} = useApi();

View File

@ -1,7 +1,7 @@
import {Dropzone} from "./Dropzone";
import {GlobalStats} from "./GlobalStats";
import {FooterLinks} from "./FooterLinks";
import {MetricsGraph} from "./MetricsGraph";
import {Dropzone} from "../Components/FileUpload/Dropzone";
import {GlobalStats} from "../Components/HomePage/GlobalStats";
import {FooterLinks} from "../Components/HomePage/FooterLinks";
import {MetricsGraph} from "../Components/HomePage/MetricsGraph";
import {useSelector} from "react-redux";
export function HomePage() {

View File

@ -1,16 +1,17 @@
import {Fragment, useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import {useApi} from "./Api";
import {ApiHost, DefaultAvatar, UserFlags} from "./Const";
import "./Profile.css";
import {Fragment, useEffect, useState} from "react";
import {useDispatch, useSelector} from "react-redux";
import {logout, setProfile as setGlobalProfile} from "./LoginState";
import {DigestAlgo} from "./FileUpload";
import {buf2hex, hasFlag} from "./Util";
import moment from "moment";
import {FileList} from "./FileList";
import {VoidButton} from "./VoidButton";
import ApiKeyList from "./ApiKeyList";
import {useParams} from "react-router-dom";
import {useApi} from "../Components/Shared/Api";
import {ApiHost, DefaultAvatar, UserFlags} from "../Components/Shared/Const";
import {logout, setProfile as setGlobalProfile} from "../LoginState";
import {DigestAlgo} from "../Components/FileUpload/FileUpload";
import {buf2hex, hasFlag} from "../Components/Shared/Util";
import {FileList} from "../Components/Shared/FileList";
import {VoidButton} from "../Components/Shared/VoidButton";
import ApiKeyList from "../Components/Profile/ApiKeyList";
export function Profile() {
const [profile, setProfile] = useState();

View File

@ -1,4 +1,4 @@
import {Login} from "./Login";
import {Login} from "../Components/Shared/Login";
import {useSelector} from "react-redux";
import {useNavigate} from "react-router-dom";
import {useEffect} from "react";