Add hCaptcha to login page

This commit is contained in:
Kieran 2022-03-11 15:59:08 +00:00
parent 8b546c6437
commit 80880a18a8
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
17 changed files with 214 additions and 49 deletions

View File

@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using VoidCat.Model;
@ -15,11 +14,13 @@ public class AuthController : Controller
{
private readonly IUserManager _manager;
private readonly VoidSettings _settings;
private readonly ICaptchaVerifier _captchaVerifier;
public AuthController(IUserManager userStore, VoidSettings settings)
public AuthController(IUserManager userStore, VoidSettings settings, ICaptchaVerifier captchaVerifier)
{
_manager = userStore;
_settings = settings;
_captchaVerifier = captchaVerifier;
}
/// <summary>
@ -39,6 +40,12 @@ public class AuthController : Controller
return new(null, error);
}
// check captcha
if (!await _captchaVerifier.Verify(req.Captcha))
{
return new(null, "Captcha verification failed");
}
var user = await _manager.Login(req.Username, req.Password);
var token = CreateToken(user);
var tokenWriter = new JwtSecurityTokenHandler();
@ -67,6 +74,12 @@ public class AuthController : Controller
return new(null, error);
}
// check captcha
if (!await _captchaVerifier.Verify(req.Captcha))
{
return new(null, "Captcha verification failed");
}
var newUser = await _manager.Register(req.Username, req.Password);
var token = CreateToken(newUser);
var tokenWriter = new JwtSecurityTokenHandler();
@ -96,7 +109,7 @@ public class AuthController : Controller
}
public class LoginRequest
public sealed class LoginRequest
{
public LoginRequest(string username, string password)
{
@ -106,12 +119,14 @@ public class AuthController : Controller
[Required]
[EmailAddress]
public string Username { get; init; }
public string Username { get; }
[Required]
[MinLength(6)]
public string Password { get; init; }
public string Password { get; }
public string? Captcha { get; init; }
}
public record LoginResponse(string? Jwt, string? Error = null, VoidUser? Profile = null);
public sealed record LoginResponse(string? Jwt, string? Error = null, VoidUser? Profile = null);
}

View File

@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Controllers;
[Route("info")]
public class InfoController : Controller
{
private readonly IStatsReporter _statsReporter;
private readonly IFileStore _fileStore;
private readonly VoidSettings _settings;
public InfoController(IStatsReporter statsReporter, IFileStore fileStore, VoidSettings settings)
{
_statsReporter = statsReporter;
_fileStore = fileStore;
_settings = settings;
}
/// <summary>
/// Return system info
/// </summary>
/// <returns></returns>
[HttpGet]
[ResponseCache(Location = ResponseCacheLocation.Any, Duration = 60)]
public async Task<GlobalInfo> GetGlobalStats()
{
var bw = await _statsReporter.GetBandwidth();
var bytes = 0UL;
var count = 0;
var files = await _fileStore.ListFiles(new(0, Int32.MaxValue));
await foreach (var vf in files.Results)
{
bytes += vf.Metadata?.Size ?? 0;
count++;
}
return new(bw, bytes, count, BuildInfo.GetBuildInfo(), _settings.CaptchaSettings?.SiteKey);
}
public sealed record GlobalInfo(Bandwidth Bandwidth, ulong TotalBytes, int Count, BuildInfo BuildInfo,
string? CaptchaSiteKey);
}

View File

@ -16,28 +16,6 @@ namespace VoidCat.Controllers
_fileStore = fileStore;
}
/// <summary>
/// Return system info
/// </summary>
/// <returns></returns>
[HttpGet]
[ResponseCache(Location = ResponseCacheLocation.Any, Duration = 60)]
public async Task<GlobalStats> GetGlobalStats()
{
var bw = await _statsReporter.GetBandwidth();
var bytes = 0UL;
var count = 0;
var files = await _fileStore.ListFiles(new(0, Int32.MaxValue));
await foreach (var vf in files.Results)
{
bytes += vf.Metadata?.Size ?? 0;
count++;
}
return new(bw, bytes, count, BuildInfo.GetBuildInfo());
}
/// <summary>
/// Get stats for a specific file
/// </summary>
@ -52,7 +30,5 @@ namespace VoidCat.Controllers
}
}
public sealed record GlobalStats(Bandwidth Bandwidth, ulong TotalBytes, int Count, BuildInfo BuildInfo);
public sealed record FileStats(Bandwidth Bandwidth);
}

View File

@ -25,6 +25,10 @@ namespace VoidCat.Model
public CloudStorageSettings? CloudStorage { get; init; }
public VirusScannerSettings? VirusScanner { get; init; }
public IEnumerable<string>? RequestHeadersLog { get; init; }
public CaptchaSettings? CaptchaSettings { get; init; }
}
public sealed class TorSettings
@ -78,4 +82,10 @@ namespace VoidCat.Model
{
public string? ApiKey { get; init; }
}
public sealed class CaptchaSettings
{
public string? SiteKey { get; init; }
public string? Secret { get; init; }
}
}

View File

@ -11,6 +11,7 @@ using VoidCat.Model;
using VoidCat.Services;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Background;
using VoidCat.Services.Captcha;
using VoidCat.Services.Files;
using VoidCat.Services.InMemory;
using VoidCat.Services.Migrations;
@ -52,10 +53,17 @@ if (useRedis)
services.AddHttpLogging((o) =>
{
o.LoggingFields = HttpLoggingFields.RequestHeaders | HttpLoggingFields.ResponseHeaders | HttpLoggingFields.Response;
o.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.ResponsePropertiesAndHeaders;
o.RequestBodyLogLimit = 4096;
o.ResponseBodyLogLimit = 4096;
o.MediaTypeOptions.Clear();
o.MediaTypeOptions.AddText("application/json");
foreach (var h in voidSettings.RequestHeadersLog ?? Enumerable.Empty<string>())
{
o.RequestHeaders.Add(h);
}
});
services.AddHttpClient();
services.AddSwaggerGen(c =>
@ -143,6 +151,9 @@ services.AddHostedService<DeleteUnverifiedAccounts>();
// virus scanner
services.AddVirusScanner(voidSettings);
// captcha
services.AddCaptcha(voidSettings);
if (useRedis)
{
services.AddTransient<ICache, RedisCache>();

View File

@ -0,0 +1,6 @@
namespace VoidCat.Services.Abstractions;
public interface ICaptchaVerifier
{
ValueTask<bool> Verify(string? token);
}

View File

@ -0,0 +1,19 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Captcha;
public static class CaptchaStartup
{
public static void AddCaptcha(this IServiceCollection services, VoidSettings settings)
{
if (settings.CaptchaSettings != default)
{
services.AddTransient<ICaptchaVerifier, hCaptchaVerifier>();
}
else
{
services.AddTransient<ICaptchaVerifier, NoOpVerifier>();
}
}
}

View File

@ -0,0 +1,11 @@
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Captcha;
public class NoOpVerifier : ICaptchaVerifier
{
public ValueTask<bool> Verify(string? token)
{
return ValueTask.FromResult(token == null);
}
}

View File

@ -0,0 +1,45 @@
using System.Net;
using Newtonsoft.Json;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Captcha;
public class hCaptchaVerifier : ICaptchaVerifier
{
private readonly IHttpClientFactory _clientFactory;
private readonly VoidSettings _settings;
public hCaptchaVerifier(IHttpClientFactory clientFactory, VoidSettings settings)
{
_clientFactory = clientFactory;
_settings = settings;
}
public async ValueTask<bool> Verify(string? token)
{
if (string.IsNullOrEmpty(token)) return false;
var req = new HttpRequestMessage(HttpMethod.Post, "https://hcaptcha.com/siteverify");
req.Content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("response", token),
new KeyValuePair<string, string>("secret", _settings.CaptchaSettings!.Secret!)
});
using var cli = _clientFactory.CreateClient();
var rsp = await cli.SendAsync(req);
if (rsp.StatusCode == HttpStatusCode.OK)
{
var body = JsonConvert.DeserializeObject<hCaptchaResponse>(await rsp.Content.ReadAsStringAsync());
return body?.Success == true;
}
return false;
}
internal sealed class hCaptchaResponse
{
public bool Success { get; init; }
}
}

View File

@ -4,6 +4,7 @@
"private": true,
"proxy": "https://localhost:7195",
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.1.1",
"@reduxjs/toolkit": "^1.7.2",
"feather-icons-react": "^0.5.0",
"moment": "^2.29.1",

View File

@ -30,13 +30,13 @@ export function useApi() {
userList: (pageReq) => getJson("POST", `/admin/user`, pageReq, auth)
},
Api: {
stats: () => getJson("GET", "/stats"),
info: () => getJson("GET", "/info"),
fileInfo: (id) => getJson("GET", `/upload/${id}`),
setPaywallConfig: (id, cfg) => getJson("POST", `/upload/${id}/paywall`, cfg, auth),
createOrder: (id) => getJson("GET", `/upload/${id}/paywall`),
getOrder: (file, order) => getJson("GET", `/upload/${file}/paywall/${order}`),
login: (username, password) => getJson("POST", `/auth/login`, {username, password}),
register: (username, password) => getJson("POST", `/auth/register`, {username, password}),
login: (username, password, captcha) => getJson("POST", `/auth/login`, {username, password, captcha}),
register: (username, password, captcha) => getJson("POST", `/auth/register`, {username, password, captcha}),
getUser: (id) => getJson("GET", `/user/${id}`, undefined, auth),
updateUser: (u) => getJson("POST", `/user/${u.id}`, u, auth),
listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth),

View File

@ -5,19 +5,11 @@ import {FormatBytes} from "./Util";
import "./GlobalStats.css";
import {useApi} from "./Api";
import moment from "moment";
import {useSelector} from "react-redux";
export function GlobalStats(props) {
const {Api} = useApi();
let [stats, setStats] = useState();
async function loadStats() {
let req = await Api.stats();
if (req.ok) {
setStats(await req.json());
}
}
useEffect(() => loadStats(), []);
let stats = useSelector(state => state.info.stats);
return (
<Fragment>

View File

@ -5,6 +5,7 @@ import {InlineProfile} from "./InlineProfile";
import {useApi} from "./Api";
import {logout, setProfile} from "./LoginState";
import {useEffect} from "react";
import {setStats} from "./SiteInfoStore";
export function Header() {
const dispatch = useDispatch();
@ -22,9 +23,16 @@ export function Header() {
}
}
}
async function loadStats() {
let req = await Api.info();
if (req.ok) {
dispatch(setStats(await req.json()));
}
}
useEffect(() => {
initProfile();
loadStats();
}, []);
return (

View File

@ -1,22 +1,25 @@
import {useState} from "react";
import {useDispatch} from "react-redux";
import {useDispatch, useSelector} from "react-redux";
import {setAuth} from "./LoginState";
import {useApi} from "./Api";
import "./Login.css";
import {btnDisable, btnEnable} from "./Util";
import HCaptcha from "@hcaptcha/react-hcaptcha";
export function Login() {
const {Api} = useApi();
const [username, setUsername] = useState();
const [password, setPassword] = useState();
const [error, setError] = useState();
const [captchaResponse, setCaptchaResponse] = useState();
const captchaKey = useSelector(state => state.info.stats.captchaSiteKey);
const dispatch = useDispatch();
async function login(e, fnLogin) {
if(!btnDisable(e.target)) return;
setError(null);
let req = await fnLogin(username, password);
let req = await fnLogin(username, password, captchaResponse);
if (req.ok) {
let rsp = await req.json();
if (rsp.jwt) {
@ -38,6 +41,7 @@ export function Login() {
<dt>Password:</dt>
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
</dl>
{captchaKey ? <HCaptcha sitekey={captchaKey} onVerify={setCaptchaResponse}/> : null}
<div className="btn" onClick={(e) => login(e, Api.login)}>Login</div>
<div className="btn" onClick={(e) => login(e, Api.register)}>Register</div>
{error ? <div className="error-msg">{error}</div> : null}

View File

@ -0,0 +1,16 @@
import {createSlice} from "@reduxjs/toolkit";
export const SiteInfoState = createSlice({
name: "SiteInfo",
initialState: {
stats: null
},
reducers: {
setStats: (state, action) => {
state.stats = action.payload;
},
}
});
export const {setStats} = SiteInfoState.actions;
export default SiteInfoState.reducer;

View File

@ -1,8 +1,10 @@
import {configureStore} from "@reduxjs/toolkit";
import loginReducer from "./LoginState";
import siteInfoReducer from "./SiteInfoStore";
export default configureStore({
reducer: {
login: loginReducer
login: loginReducer,
info: siteInfoReducer
}
});

View File

@ -1087,6 +1087,11 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@hcaptcha/react-hcaptcha@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.1.1.tgz#b7f9fef825fe52595e38b61f125dc8d87d2a5440"
integrity sha512-fydc0ob5NOEYiJPmohOmPqoBjLPhUokvMZBf1hOhrVcpMyZJbc1N/HPV4kysSayC34IQsaBRtEpswQFbHwe54w==
"@humanwhocodes/config-array@^0.9.2":
version "0.9.2"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.2.tgz#68be55c737023009dfc5fe245d51181bb6476914"