c# rewrite
This commit is contained in:
parent
712e3294ac
commit
82bc13de67
11 changed files with 793 additions and 313 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
credentials.db
|
23
Dockerfile
23
Dockerfile
|
@ -1,11 +1,12 @@
|
||||||
from alpine:latest
|
from mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
||||||
run apk update && apk upgrade
|
|
||||||
run apk add python3 py3-virtualenv
|
workdir /App
|
||||||
run adduser -D -u 1000 webauthn
|
copy ./src/ ./
|
||||||
copy . /opt/app
|
run dotnet restore
|
||||||
run chown -R webauthn:webauthn /opt/app
|
run dotnet publish -c Release -o out
|
||||||
user webauthn
|
|
||||||
workdir /opt/app
|
from mcr.microsoft.com/dotnet/aspnet:7.0
|
||||||
run virtualenv venv
|
|
||||||
run . venv/bin/activate && pip install -r requirements.txt
|
workdir /App
|
||||||
cmd ["./run.sh"]
|
copy --from=build-env /App/out .
|
||||||
|
entrypoint ["dotnet", "webauthn-proxy.dll"]
|
||||||
|
|
296
main.py
296
main.py
|
@ -1,296 +0,0 @@
|
||||||
from json.decoder import JSONDecodeError
|
|
||||||
import datetime
|
|
||||||
import random
|
|
||||||
import socketserver
|
|
||||||
import http.server
|
|
||||||
import http.cookies
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fido2.client import ClientData
|
|
||||||
from fido2.webauthn import PublicKeyCredentialRpEntity
|
|
||||||
from fido2.server import U2FFido2Server
|
|
||||||
from fido2.ctap2 import AttestationObject, AttestedCredentialData, AuthenticatorData
|
|
||||||
from fido2 import cbor
|
|
||||||
|
|
||||||
TOKEN_LIFETIME = 60 * 60 * 24 * 7
|
|
||||||
PORT = 8000
|
|
||||||
FORM = """
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
function atobarray(sBase64) {
|
|
||||||
var sBinaryString = atob(sBase64), aBinaryView = new Uint8Array(sBinaryString.length);
|
|
||||||
Array.prototype.forEach.call(aBinaryView, function (el, idx, arr) { arr[idx] = sBinaryString.charCodeAt(idx); });
|
|
||||||
return aBinaryView;
|
|
||||||
}
|
|
||||||
|
|
||||||
function barraytoa(arrayBuffer) {
|
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function configure() {
|
|
||||||
try {
|
|
||||||
let data = await fetch('/auth/get_challenge_for_new_key', { method: 'POST' });
|
|
||||||
let json = await data.json()
|
|
||||||
json.publicKey.challenge = atobarray(json.publicKey.challenge)
|
|
||||||
json.publicKey.user.id = atobarray(json.publicKey.user.id)
|
|
||||||
let cred = await navigator.credentials.create(json)
|
|
||||||
window.command.innerHTML = 'On your server, to save this key please run:<br /><pre>python main.py save-client ' + window.location.host + ' ' + barraytoa(cred.response.clientDataJSON) + ' ' + barraytoa(cred.response.attestationObject) + '</pre>'
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(async function init() {
|
|
||||||
try {
|
|
||||||
let data = await fetch('/auth/get_challenge_for_existing_key', { method: 'POST' });
|
|
||||||
let json = await data.json()
|
|
||||||
if (json.publicKey !== undefined) {
|
|
||||||
json.publicKey.challenge = atobarray(json.publicKey.challenge)
|
|
||||||
for (let i = 0; i < json.publicKey.allowCredentials.length; i++) {
|
|
||||||
json.publicKey.allowCredentials[i].id = atobarray(json.publicKey.allowCredentials[i].id)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var result = await navigator.credentials.get(json)
|
|
||||||
} catch(e) {
|
|
||||||
console.log('unknown key')
|
|
||||||
await configure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await fetch('/auth/complete_challenge_for_existing_key', { method: 'POST', body: JSON.stringify({
|
|
||||||
id: barraytoa(result.rawId),
|
|
||||||
authenticatorData: barraytoa(result.response.authenticatorData),
|
|
||||||
clientDataJSON: barraytoa(result.response.clientDataJSON),
|
|
||||||
signature: barraytoa(result.response.signature)
|
|
||||||
}), headers:{ 'Content-Type': 'application/json' }})
|
|
||||||
let params = await new URLSearchParams(window.location.search)
|
|
||||||
if (params.has('target')) {
|
|
||||||
window.location.href = params.get('target')
|
|
||||||
} else {
|
|
||||||
window.location.href = "/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (json.error == 'not_configured') {
|
|
||||||
await configure()
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</script>
|
|
||||||
<div id="command"></div>
|
|
||||||
</body>
|
|
||||||
"""
|
|
||||||
|
|
||||||
class TokenManager(object):
|
|
||||||
"""Who needs a database when you can just store everything in memory?"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.tokens = {}
|
|
||||||
self.random = random.SystemRandom()
|
|
||||||
|
|
||||||
def generate(self):
|
|
||||||
t = '%064x' % self.random.getrandbits(8*32)
|
|
||||||
self.tokens[t] = time.time()
|
|
||||||
return t
|
|
||||||
|
|
||||||
def is_valid(self, t):
|
|
||||||
try:
|
|
||||||
return time.time() - self.tokens.get(t, 0) < TOKEN_LIFETIME
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def invalidate(self, t):
|
|
||||||
if t in self.tokens:
|
|
||||||
del self.tokens[t]
|
|
||||||
|
|
||||||
CHALLENGE = {}
|
|
||||||
TOKEN_MANAGER = TokenManager()
|
|
||||||
|
|
||||||
class AuthHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == '/auth/check':
|
|
||||||
cookie = http.cookies.SimpleCookie(self.headers.get('Cookie'))
|
|
||||||
if '__Secure-Token' in cookie and TOKEN_MANAGER.is_valid(cookie['__Secure-Token'].value):
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_response(401)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path[:11] == "/auth/login":
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-type', 'text/html')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(bytes(FORM, 'UTF-8'))
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path == '/auth/logout':
|
|
||||||
cookie = http.cookies.SimpleCookie(self.headers.get('Cookie'))
|
|
||||||
if '__Secure-Token' in cookie:
|
|
||||||
TOKEN_MANAGER.invalidate(cookie['__Secure-Token'].value)
|
|
||||||
|
|
||||||
# This just replaces the token with garbage
|
|
||||||
self.send_response(302)
|
|
||||||
cookie = http.cookies.SimpleCookie()
|
|
||||||
cookie["__Secure-Token"] = ''
|
|
||||||
cookie["__Secure-Token"]["path"] = '/'
|
|
||||||
cookie["__Secure-Token"]["secure"] = True
|
|
||||||
cookie["__Secure-Token"]["max-age"] = 0 # remove the cookie ASAP
|
|
||||||
self.send_header('Set-Cookie', cookie.output(header=''))
|
|
||||||
self.send_header('Location', '/')
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
self.send_response(404)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
origin = self.headers.get('Origin')
|
|
||||||
host = origin[len('https://'):]
|
|
||||||
|
|
||||||
rp = PublicKeyCredentialRpEntity(host, 'NGINX Auth Server')
|
|
||||||
server = U2FFido2Server(origin, rp)
|
|
||||||
|
|
||||||
if self.path == "/auth/get_challenge_for_new_key":
|
|
||||||
# check whether credentials file is locked (can't be written)
|
|
||||||
if os.access('.credentials', os.F_OK) and not os.access('.credentials', os.W_OK):
|
|
||||||
self.send_response(403)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
registration_data, state = server.register_begin({ 'id': b'default', 'name': "Default user", 'displayName': "Default user" })
|
|
||||||
registration_data["publicKey"]["challenge"] = str(base64.b64encode(registration_data["publicKey"]["challenge"]), 'utf-8')
|
|
||||||
registration_data["publicKey"]["user"]["id"] = str(base64.b64encode(registration_data["publicKey"]["user"]["id"]), 'utf-8')
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-type', 'application/json')
|
|
||||||
self.end_headers()
|
|
||||||
# Save this challenge to a file so you can kill the host to add the client via CLI
|
|
||||||
with open('data/lastchallenge', 'w') as f:
|
|
||||||
f.write(json.dumps(state))
|
|
||||||
self.wfile.write(bytes(json.dumps(registration_data), 'UTF-8'))
|
|
||||||
return
|
|
||||||
|
|
||||||
creds = []
|
|
||||||
try:
|
|
||||||
with open('data/credentials', 'r', encoding='utf8') as f:
|
|
||||||
for cred_b64 in json.load(f):
|
|
||||||
cred, _ = AttestedCredentialData.unpack_from(base64.b64decode(cred_b64))
|
|
||||||
creds.append(cred)
|
|
||||||
except:
|
|
||||||
self.send_response(401)
|
|
||||||
self.send_header('Content-type', 'application/json')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(bytes(json.dumps({'error': 'not_configured'}), 'UTF-8'))
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path == "/auth/get_challenge_for_existing_key":
|
|
||||||
auth_data, state = server.authenticate_begin(creds)
|
|
||||||
auth_data["publicKey"]["challenge"] = str(base64.b64encode(auth_data["publicKey"]["challenge"]), 'utf-8')
|
|
||||||
for i in range(len(auth_data["publicKey"]["allowCredentials"])):
|
|
||||||
auth_data["publicKey"]["allowCredentials"][i]["id"] = base64.b64encode(auth_data["publicKey"]["allowCredentials"][i]["id"]).decode('utf8')
|
|
||||||
|
|
||||||
CHALLENGE.update(state)
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-type', 'application/json')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(bytes(json.dumps(auth_data), 'UTF-8'))
|
|
||||||
|
|
||||||
if self.path == "/auth/complete_challenge_for_existing_key":
|
|
||||||
data = json.loads(self.rfile.read(int(self.headers.get('Content-Length'))))
|
|
||||||
|
|
||||||
credential_id = base64.b64decode(data['id'])
|
|
||||||
client_data = ClientData(base64.b64decode(data['clientDataJSON']))
|
|
||||||
auth_data = AuthenticatorData(base64.b64decode(data['authenticatorData']))
|
|
||||||
signature = base64.b64decode(data['signature'])
|
|
||||||
|
|
||||||
server.authenticate_complete(
|
|
||||||
CHALLENGE,
|
|
||||||
creds,
|
|
||||||
credential_id,
|
|
||||||
client_data,
|
|
||||||
auth_data,
|
|
||||||
signature
|
|
||||||
)
|
|
||||||
|
|
||||||
cookie = http.cookies.SimpleCookie()
|
|
||||||
cookie["__Secure-Token"] = TOKEN_MANAGER.generate()
|
|
||||||
cookie["__Secure-Token"]["path"] = "/"
|
|
||||||
cookie["__Secure-Token"]["secure"] = True
|
|
||||||
cookie["__Secure-Token"]["httponly"] = True
|
|
||||||
cookie["__Secure-Token"]["samesite"] = 'None'
|
|
||||||
cookie["__Secure-Token"]["domain"] = 'rdsm.ca'
|
|
||||||
cookie["__Secure-Token"]["max-age"] = TOKEN_LIFETIME
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Set-Cookie', cookie.output(header=''))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(bytes(json.dumps({'status': 'ok'}), 'UTF-8'))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
|
||||||
def run_server():
|
|
||||||
socketserver.TCPServer.allow_reuse_address = True
|
|
||||||
httpd = socketserver.TCPServer(("", PORT), AuthHandler)
|
|
||||||
try:
|
|
||||||
httpd.serve_forever()
|
|
||||||
finally:
|
|
||||||
httpd.server_close()
|
|
||||||
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "save-client":
|
|
||||||
host = sys.argv[2]
|
|
||||||
client_data = ClientData(base64.b64decode(sys.argv[3]))
|
|
||||||
attestation_object = AttestationObject(base64.b64decode(sys.argv[4]))
|
|
||||||
|
|
||||||
rp = PublicKeyCredentialRpEntity(host, 'NGINX Auth Server')
|
|
||||||
server = U2FFido2Server('https://' + host, rp)
|
|
||||||
|
|
||||||
with open('data/lastchallenge') as f:
|
|
||||||
auth_data = server.register_complete(json.loads(f.read()), client_data, attestation_object)
|
|
||||||
cred = base64.b64encode(auth_data.credential_data).decode('utf8')
|
|
||||||
with open('data/credentials', 'a+', encoding='utf8') as f:
|
|
||||||
f.seek(0)
|
|
||||||
try:
|
|
||||||
creds = json.load(f)
|
|
||||||
except JSONDecodeError:
|
|
||||||
creds = []
|
|
||||||
print("Created new credentials file")
|
|
||||||
if cred not in creds:
|
|
||||||
creds.append(cred)
|
|
||||||
f.truncate(0)
|
|
||||||
json.dump(creds, f)
|
|
||||||
print("Credentials saved successfully")
|
|
||||||
else:
|
|
||||||
print("Credentials already in database")
|
|
||||||
|
|
||||||
elif len(sys.argv) == 2 and sys.argv[1] == '-d':
|
|
||||||
# starts the server non-daemonized
|
|
||||||
run_server()
|
|
||||||
|
|
||||||
else:
|
|
||||||
from daemon import DaemonContext
|
|
||||||
from sys import stdout, stderr, exit
|
|
||||||
from lockfile import FileLock
|
|
||||||
from signal import SIGTERM, SIGTSTP
|
|
||||||
|
|
||||||
def shutdown(signum, frame):
|
|
||||||
exit(0)
|
|
||||||
|
|
||||||
with DaemonContext(
|
|
||||||
chroot_directory=None,
|
|
||||||
working_directory='/opt/app',
|
|
||||||
stdout=stdout,
|
|
||||||
stderr=stderr,
|
|
||||||
pidfile=FileLock('/var/run/webauthn/webauthn.pid'),
|
|
||||||
signal_map={
|
|
||||||
SIGTERM: shutdown,
|
|
||||||
SIGTSTP: shutdown
|
|
||||||
}):
|
|
||||||
run_server()
|
|
|
@ -1,2 +0,0 @@
|
||||||
fido2==0.8.1
|
|
||||||
python-daemon
|
|
4
run.sh
4
run.sh
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
. venv/bin/activate
|
|
||||||
exec python main.py -d
|
|
323
src/.editorconfig
Normal file
323
src/.editorconfig
Normal file
|
@ -0,0 +1,323 @@
|
||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Don't use tabs for indentation.
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
# (Please don't specify an indent_size here; that has too many unintended consequences.)
|
||||||
|
|
||||||
|
# Code files
|
||||||
|
[*.{cs,csx,vb,vbx}]
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8-bom
|
||||||
|
|
||||||
|
# XML project files
|
||||||
|
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# XML config files
|
||||||
|
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# JSON files
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Powershell files
|
||||||
|
[*.ps1]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Shell script files
|
||||||
|
[*.sh]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Dotnet code style settings:
|
||||||
|
[*.{cs,vb}]
|
||||||
|
|
||||||
|
# IDE0055: Fix formatting
|
||||||
|
dotnet_diagnostic.IDE0055.severity = warning
|
||||||
|
|
||||||
|
# CA1848: Use LoggerDelegates
|
||||||
|
dotnet_diagnostic.CA1848.severity = none
|
||||||
|
|
||||||
|
# Sort using and Import directives with System.* appearing first
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
dotnet_separate_import_directive_groups = false
|
||||||
|
# Avoid "this." and "Me." if not necessary
|
||||||
|
dotnet_style_qualification_for_field = false:refactoring
|
||||||
|
dotnet_style_qualification_for_property = false:refactoring
|
||||||
|
dotnet_style_qualification_for_method = false:refactoring
|
||||||
|
dotnet_style_qualification_for_event = false:refactoring
|
||||||
|
|
||||||
|
# Use language keywords instead of framework type names for type references
|
||||||
|
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||||
|
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||||
|
|
||||||
|
# Suggest more modern language features when available
|
||||||
|
dotnet_style_object_initializer = true:suggestion
|
||||||
|
dotnet_style_collection_initializer = true:suggestion
|
||||||
|
dotnet_style_coalesce_expression = true:suggestion
|
||||||
|
dotnet_style_null_propagation = true:suggestion
|
||||||
|
dotnet_style_explicit_tuple_names = true:suggestion
|
||||||
|
|
||||||
|
# Whitespace options
|
||||||
|
dotnet_style_allow_multiple_blank_lines_experimental = false
|
||||||
|
|
||||||
|
# Non-private static fields are PascalCase
|
||||||
|
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
|
||||||
|
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
|
||||||
|
|
||||||
|
dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
|
||||||
|
|
||||||
|
# Non-private readonly fields are PascalCase
|
||||||
|
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields
|
||||||
|
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly
|
||||||
|
|
||||||
|
dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case
|
||||||
|
|
||||||
|
# Constants are PascalCase
|
||||||
|
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
|
||||||
|
dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.constants.applicable_kinds = field, local
|
||||||
|
dotnet_naming_symbols.constants.required_modifiers = const
|
||||||
|
|
||||||
|
dotnet_naming_style.constant_style.capitalization = pascal_case
|
||||||
|
|
||||||
|
# Static fields are camelCase and start with s_
|
||||||
|
dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
|
||||||
|
dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.static_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.static_fields.required_modifiers = static
|
||||||
|
|
||||||
|
dotnet_naming_style.static_field_style.capitalization = camel_case
|
||||||
|
dotnet_naming_style.static_field_style.required_prefix = s_
|
||||||
|
|
||||||
|
# Instance fields are camelCase and start with _
|
||||||
|
dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
|
||||||
|
dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.instance_fields.applicable_kinds = field
|
||||||
|
|
||||||
|
dotnet_naming_style.instance_field_style.capitalization = camel_case
|
||||||
|
dotnet_naming_style.instance_field_style.required_prefix = _
|
||||||
|
|
||||||
|
# Locals and parameters are camelCase
|
||||||
|
dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
|
||||||
|
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
|
||||||
|
|
||||||
|
dotnet_naming_style.camel_case_style.capitalization = camel_case
|
||||||
|
|
||||||
|
# Local functions are PascalCase
|
||||||
|
dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
|
||||||
|
dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
|
||||||
|
|
||||||
|
dotnet_naming_style.local_function_style.capitalization = pascal_case
|
||||||
|
|
||||||
|
# By default, name items with PascalCase
|
||||||
|
dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
|
||||||
|
dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.all_members.applicable_kinds = *
|
||||||
|
|
||||||
|
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
|
||||||
|
|
||||||
|
# error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}'
|
||||||
|
dotnet_diagnostic.RS2008.severity = none
|
||||||
|
|
||||||
|
# IDE0073: File header
|
||||||
|
dotnet_diagnostic.IDE0073.severity = none
|
||||||
|
file_header_template =
|
||||||
|
|
||||||
|
# IDE0035: Remove unreachable code
|
||||||
|
dotnet_diagnostic.IDE0035.severity = warning
|
||||||
|
|
||||||
|
# IDE0036: Order modifiers
|
||||||
|
dotnet_diagnostic.IDE0036.severity = warning
|
||||||
|
|
||||||
|
# IDE0043: Format string contains invalid placeholder
|
||||||
|
dotnet_diagnostic.IDE0043.severity = warning
|
||||||
|
|
||||||
|
# IDE0044: Make field readonly
|
||||||
|
dotnet_diagnostic.IDE0044.severity = warning
|
||||||
|
|
||||||
|
# RS0016: Only enable if API files are present
|
||||||
|
dotnet_public_api_analyzer.require_api_files = true
|
||||||
|
|
||||||
|
# CSharp code style settings:
|
||||||
|
[*.cs]
|
||||||
|
|
||||||
|
# enforce file scoped namespaces
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
|
|
||||||
|
# Newline settings
|
||||||
|
csharp_new_line_before_open_brace = none
|
||||||
|
csharp_new_line_before_else = false
|
||||||
|
csharp_new_line_before_catch = false
|
||||||
|
csharp_new_line_before_finally = false
|
||||||
|
csharp_new_line_before_members_in_object_initializers = true
|
||||||
|
csharp_new_line_before_members_in_anonymous_types = true
|
||||||
|
csharp_new_line_between_query_expression_clauses = true
|
||||||
|
|
||||||
|
# Indentation preferences
|
||||||
|
csharp_indent_block_contents = true
|
||||||
|
csharp_indent_braces = false
|
||||||
|
csharp_indent_case_contents = true
|
||||||
|
csharp_indent_case_contents_when_block = false
|
||||||
|
csharp_indent_switch_labels = true
|
||||||
|
csharp_indent_labels = flush_left
|
||||||
|
|
||||||
|
# Whitespace options
|
||||||
|
csharp_style_allow_embedded_statements_on_same_line_experimental = true
|
||||||
|
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false
|
||||||
|
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false
|
||||||
|
|
||||||
|
# Prefer "var" everywhere
|
||||||
|
csharp_style_var_for_built_in_types = true:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
|
|
||||||
|
# Prefer method-like constructs to have a block body
|
||||||
|
csharp_style_expression_bodied_methods = false:none
|
||||||
|
csharp_style_expression_bodied_constructors = false:none
|
||||||
|
csharp_style_expression_bodied_operators = false:none
|
||||||
|
|
||||||
|
# Prefer property-like constructs to have an expression-body
|
||||||
|
csharp_style_expression_bodied_properties = true:none
|
||||||
|
csharp_style_expression_bodied_indexers = true:none
|
||||||
|
csharp_style_expression_bodied_accessors = true:none
|
||||||
|
|
||||||
|
# Suggest more modern language features when available
|
||||||
|
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||||
|
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||||
|
csharp_style_inlined_variable_declaration = true:suggestion
|
||||||
|
csharp_style_throw_expression = true:suggestion
|
||||||
|
csharp_style_conditional_delegate_call = true:suggestion
|
||||||
|
|
||||||
|
# Space preferences
|
||||||
|
csharp_space_after_cast = false
|
||||||
|
csharp_space_after_colon_in_inheritance_clause = true
|
||||||
|
csharp_space_after_comma = true
|
||||||
|
csharp_space_after_dot = false
|
||||||
|
csharp_space_after_keywords_in_control_flow_statements = true
|
||||||
|
csharp_space_after_semicolon_in_for_statement = true
|
||||||
|
csharp_space_around_binary_operators = before_and_after
|
||||||
|
csharp_space_around_declaration_statements = do_not_ignore
|
||||||
|
csharp_space_before_colon_in_inheritance_clause = true
|
||||||
|
csharp_space_before_comma = false
|
||||||
|
csharp_space_before_dot = false
|
||||||
|
csharp_space_before_open_square_brackets = false
|
||||||
|
csharp_space_before_semicolon_in_for_statement = false
|
||||||
|
csharp_space_between_empty_square_brackets = false
|
||||||
|
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||||
|
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_declaration_name_and_open_parenthesis = false
|
||||||
|
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_parentheses = false
|
||||||
|
csharp_space_between_square_brackets = false
|
||||||
|
|
||||||
|
# Blocks are allowed
|
||||||
|
csharp_prefer_braces = when_multiline:warning
|
||||||
|
csharp_preserve_single_line_blocks = true
|
||||||
|
csharp_preserve_single_line_statements = true
|
||||||
|
|
||||||
|
# Currently only enabled for C# due to crash in VB analyzer. VB can be enabled once
|
||||||
|
# https://github.com/dotnet/roslyn/pull/54259 has been published.
|
||||||
|
dotnet_style_allow_statement_immediately_after_block_experimental = false
|
||||||
|
|
||||||
|
# ignore unused value expression message
|
||||||
|
csharp_style_unused_value_expression_statement_preference = discard_variable:none
|
||||||
|
|
||||||
|
# allow underscores in our enums
|
||||||
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
|
|
||||||
|
[OOT.API/Models/Contract/**.cs]
|
||||||
|
# suppress analyzers in generated code
|
||||||
|
dotnet_analyzer_diagnostic.severity = none
|
||||||
|
|
||||||
|
[src/CodeStyle/**.{cs,vb}]
|
||||||
|
# warning RS0005: Do not use generic CodeAction.Create to create CodeAction
|
||||||
|
dotnet_diagnostic.RS0005.severity = none
|
||||||
|
|
||||||
|
[src/{Analyzers,CodeStyle,Features,Workspaces,EditorFeatures,VisualStudio}/**/*.{cs,vb}]
|
||||||
|
|
||||||
|
# IDE0011: Add braces
|
||||||
|
csharp_prefer_braces = when_multiline:warning
|
||||||
|
# NOTE: We need the below severity entry for Add Braces due to https://github.com/dotnet/roslyn/issues/44201
|
||||||
|
dotnet_diagnostic.IDE0011.severity = warning
|
||||||
|
|
||||||
|
# IDE0040: Add accessibility modifiers
|
||||||
|
dotnet_diagnostic.IDE0040.severity = warning
|
||||||
|
|
||||||
|
# CONSIDER: Are IDE0051 and IDE0052 too noisy to be warnings for IDE editing scenarios? Should they be made build-only warnings?
|
||||||
|
# IDE0051: Remove unused private member
|
||||||
|
dotnet_diagnostic.IDE0051.severity = warning
|
||||||
|
|
||||||
|
# IDE0052: Remove unread private member
|
||||||
|
dotnet_diagnostic.IDE0052.severity = warning
|
||||||
|
|
||||||
|
# IDE0059: Unnecessary assignment to a value
|
||||||
|
dotnet_diagnostic.IDE0059.severity = warning
|
||||||
|
|
||||||
|
# IDE0060: Remove unused parameter
|
||||||
|
dotnet_diagnostic.IDE0060.severity = warning
|
||||||
|
|
||||||
|
# CA1012: Abstract types should not have public constructors
|
||||||
|
dotnet_diagnostic.CA1012.severity = warning
|
||||||
|
|
||||||
|
# CA1822: Make member static
|
||||||
|
dotnet_diagnostic.CA1822.severity = warning
|
||||||
|
|
||||||
|
# Prefer "var" everywhere
|
||||||
|
dotnet_diagnostic.IDE0007.severity = warning
|
||||||
|
csharp_style_var_for_built_in_types = true:warning
|
||||||
|
csharp_style_var_when_type_is_apparent = true:warning
|
||||||
|
csharp_style_var_elsewhere = true:warning
|
||||||
|
|
||||||
|
# dotnet_style_allow_multiple_blank_lines_experimental
|
||||||
|
dotnet_diagnostic.IDE2000.severity = warning
|
||||||
|
|
||||||
|
# csharp_style_allow_embedded_statements_on_same_line_experimental
|
||||||
|
dotnet_diagnostic.IDE2001.severity = warning
|
||||||
|
|
||||||
|
# csharp_style_allow_blank_lines_between_consecutive_braces_experimental
|
||||||
|
dotnet_diagnostic.IDE2002.severity = warning
|
||||||
|
|
||||||
|
# dotnet_style_allow_statement_immediately_after_block_experimental
|
||||||
|
dotnet_diagnostic.IDE2003.severity = warning
|
||||||
|
|
||||||
|
# csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental
|
||||||
|
dotnet_diagnostic.IDE2004.severity = warning
|
||||||
|
|
||||||
|
[src/{VisualStudio}/**/*.{cs,vb}]
|
||||||
|
# CA1822: Make member static
|
||||||
|
# Not enforced as a build 'warning' for 'VisualStudio' layer due to large number of false positives from https://github.com/dotnet/roslyn-analyzers/issues/3857 and https://github.com/dotnet/roslyn-analyzers/issues/3858
|
||||||
|
# Additionally, there is a risk of accidentally breaking an internal API that partners rely on though IVT.
|
||||||
|
dotnet_diagnostic.CA1822.severity = suggestion
|
311
src/Program.cs
Normal file
311
src/Program.cs
Normal file
|
@ -0,0 +1,311 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Fido2NetLib;
|
||||||
|
using Fido2NetLib.Objects;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using SQLitePCL;
|
||||||
|
|
||||||
|
namespace WebauthnProxy;
|
||||||
|
|
||||||
|
public class AddKeyRequest {
|
||||||
|
public required string Password { get; set; }
|
||||||
|
public required AuthenticatorAttestationRawResponse Response { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Program {
|
||||||
|
private const string COOKIE_NAME = "__Secure-Token";
|
||||||
|
private const string SESS_ATTESTATION_KEY = "fido2.attestationOptions";
|
||||||
|
private const string SESS_ASSERTION_KEY = "fido2.assertionOptions";
|
||||||
|
|
||||||
|
private static readonly string s_loginHtmlCache = File.ReadAllText(Path.Combine(
|
||||||
|
AppDomain.CurrentDomain.BaseDirectory, "login.html"));
|
||||||
|
private static readonly string s_domain = Environment
|
||||||
|
.GetEnvironmentVariable("WEBAUTHN_DOMAIN") ?? "localhost";
|
||||||
|
private static readonly string s_db = Environment
|
||||||
|
.GetEnvironmentVariable("WEBAUTHN_DB") ?? "credentials.db";
|
||||||
|
private static readonly long s_lifetime = long.Parse(Environment
|
||||||
|
.GetEnvironmentVariable("WEBAUTHN_LIFETIME") ?? "7",
|
||||||
|
CultureInfo.InvariantCulture) * 60 * 60 * 24;
|
||||||
|
private static readonly string? s_password = Environment
|
||||||
|
.GetEnvironmentVariable("WEBAUTHN_PASSWORD");
|
||||||
|
private static readonly int s_port = int.Parse(Environment
|
||||||
|
.GetEnvironmentVariable("WEBAUTHN_PORT") ?? "5000",
|
||||||
|
CultureInfo.InvariantCulture);
|
||||||
|
private static readonly Fido2 s_fido2 = new(new Fido2Configuration {
|
||||||
|
ServerDomain = s_domain,
|
||||||
|
ServerName = "WebauthnProxy",
|
||||||
|
Origins = new(new[] { $"http{(
|
||||||
|
s_domain == "localhost"
|
||||||
|
? string.Empty
|
||||||
|
: "s")}://{s_domain}{(
|
||||||
|
s_domain == "localhost"
|
||||||
|
? $":{s_port}"
|
||||||
|
: string.Empty)}" }),
|
||||||
|
});
|
||||||
|
private static readonly List<PublicKeyCredentialDescriptor> s_keys = new();
|
||||||
|
|
||||||
|
private static string ConnectionString { get => $"Data Source={s_db}"; }
|
||||||
|
|
||||||
|
public static void Main(string[] args) {
|
||||||
|
var app = Initialize(args);
|
||||||
|
app.UseSession();
|
||||||
|
|
||||||
|
app.MapGet("/favicon.ico", () => Results.File(Convert.FromBase64String(
|
||||||
|
$"AAABAAEAEBAAAAAAAABoBQAAFgAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAD///8{new string('A', 1788)}="),
|
||||||
|
contentType: "image/x-icon"));
|
||||||
|
|
||||||
|
app.MapGet("/auth/check", async (context) => {
|
||||||
|
var token = context.Request.Cookies[COOKIE_NAME];
|
||||||
|
Console.WriteLine(token);
|
||||||
|
if (!TokenIsValid(token)) {
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||||
|
await context.Response.WriteAsync("unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.OK;
|
||||||
|
await context.Response.WriteAsync("authorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/auth/login", async (context) => {
|
||||||
|
context.Response.ContentType = "text/html";
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.OK;
|
||||||
|
await context.Response.WriteAsync(s_loginHtmlCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/auth/logout", (context) => {
|
||||||
|
if (context.Request.Cookies[COOKIE_NAME] == null)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
context.Response.Cookies.Append(COOKIE_NAME, string.Empty, new CookieOptions {
|
||||||
|
Path = "/",
|
||||||
|
Secure = true,
|
||||||
|
HttpOnly = true,
|
||||||
|
MaxAge = TimeSpan.Zero,
|
||||||
|
Domain = s_domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
context.Response.Redirect("/");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/auth/key", async (context) => {
|
||||||
|
var options = s_fido2.GetAssertionOptions(
|
||||||
|
s_keys, UserVerificationRequirement.Discouraged);
|
||||||
|
context.Session.SetString(SESS_ASSERTION_KEY,
|
||||||
|
JsonSerializer.Serialize(options));
|
||||||
|
await context.Response.WriteAsJsonAsync(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/auth/complete", async (context) => {
|
||||||
|
var assertionResponse = await context.Request
|
||||||
|
.ReadFromJsonAsync<AuthenticatorAssertionRawResponse>();
|
||||||
|
if (assertionResponse == null) {
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = "bad response" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var optsJson = context.Session.GetString(SESS_ASSERTION_KEY);
|
||||||
|
context.Session.Remove(SESS_ASSERTION_KEY);
|
||||||
|
var opts = AssertionOptions.FromJson(optsJson);
|
||||||
|
using var connection = new SqliteConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
byte[]? pubKey;
|
||||||
|
using (var cmd = new SqliteCommand(
|
||||||
|
"select key from credentials where id=@Id",
|
||||||
|
connection)) {
|
||||||
|
var param = cmd.CreateParameter();
|
||||||
|
param.ParameterName = "@Id";
|
||||||
|
param.Value = Convert.ToBase64String(assertionResponse.Id);
|
||||||
|
cmd.Parameters.Add(param);
|
||||||
|
pubKey = cmd.ExecuteScalar() is string keyStr
|
||||||
|
? Convert.FromBase64String(keyStr)
|
||||||
|
: null;
|
||||||
|
if (pubKey == null) {
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = "bad key" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await s_fido2.MakeAssertionAsync(
|
||||||
|
assertionResponse, opts, pubKey, 0, (_, _) => Task.FromResult(true));
|
||||||
|
if (res.Status != "ok") {
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = "auth failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.Cookies.Append(
|
||||||
|
COOKIE_NAME,
|
||||||
|
GenerateToken(connection),
|
||||||
|
new CookieOptions {
|
||||||
|
Path = "/",
|
||||||
|
Secure = true,
|
||||||
|
HttpOnly = true,
|
||||||
|
SameSite = SameSiteMode.None,
|
||||||
|
Domain = s_domain,
|
||||||
|
MaxAge = TimeSpan.FromSeconds(s_lifetime),
|
||||||
|
});
|
||||||
|
await context.Response.WriteAsJsonAsync(new { status = "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/auth/new-key", async (context) => {
|
||||||
|
var user = new Fido2User {
|
||||||
|
Id = Encoding.UTF8.GetBytes("default"),
|
||||||
|
Name = "Default User",
|
||||||
|
DisplayName = "Default User",
|
||||||
|
};
|
||||||
|
var options = s_fido2.RequestNewCredential(
|
||||||
|
user,
|
||||||
|
new List<PublicKeyCredentialDescriptor>(),
|
||||||
|
AuthenticatorSelection.Default,
|
||||||
|
AttestationConveyancePreference.None);
|
||||||
|
context.Session.SetString(SESS_ATTESTATION_KEY,
|
||||||
|
JsonSerializer.Serialize(options));
|
||||||
|
|
||||||
|
await context.Response.WriteAsJsonAsync(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/auth/add-key", async (context) => {
|
||||||
|
var req = await context.Request.ReadFromJsonAsync<AddKeyRequest>();
|
||||||
|
if (req == null) {
|
||||||
|
context.Response.StatusCode = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(s_password)
|
||||||
|
|| s_password != req.Password) {
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = "bad password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var optsJson = context.Session.GetString(SESS_ATTESTATION_KEY);
|
||||||
|
context.Session.Remove(SESS_ATTESTATION_KEY);
|
||||||
|
var opts = CredentialCreateOptions.FromJson(optsJson);
|
||||||
|
var cred = await s_fido2.MakeNewCredentialAsync(
|
||||||
|
req.Response, opts, (_, _) => Task.FromResult(true));
|
||||||
|
var descriptor = new PublicKeyCredentialDescriptor(
|
||||||
|
cred.Result!.CredentialId);
|
||||||
|
|
||||||
|
// store in list and save to database
|
||||||
|
s_keys.Add(descriptor);
|
||||||
|
using var connection = new SqliteConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = new SqliteCommand(
|
||||||
|
@"insert into credentials (id, key) values (@Id, @Key)",
|
||||||
|
connection);
|
||||||
|
var idParam = cmd.CreateParameter();
|
||||||
|
idParam.ParameterName = "@Id";
|
||||||
|
idParam.Value = Convert.ToBase64String(cred.Result.CredentialId);
|
||||||
|
var keyParam = cmd.CreateParameter();
|
||||||
|
keyParam.ParameterName = "@Key";
|
||||||
|
keyParam.Value = Convert.ToBase64String(cred.Result.PublicKey);
|
||||||
|
cmd.Parameters.Add(idParam);
|
||||||
|
cmd.Parameters.Add(keyParam);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run($"http://0.0.0.0:{s_port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateToken(SqliteConnection connection) {
|
||||||
|
var rng = RandomNumberGenerator.Create();
|
||||||
|
var bytes = new byte[32];
|
||||||
|
rng.GetBytes(bytes);
|
||||||
|
var token = Convert.ToBase64String(bytes);
|
||||||
|
using var cmd = new SqliteCommand(@"insert into tokens (token) values (@Token)",
|
||||||
|
connection);
|
||||||
|
var param = cmd.CreateParameter();
|
||||||
|
param.ParameterName = "@Token";
|
||||||
|
param.Value = token;
|
||||||
|
cmd.Parameters.Add(param);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TokenIsValid(string? token) {
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||||
|
|
||||||
|
using var connection = new SqliteConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = new SqliteCommand(
|
||||||
|
@"select 1 from tokens where
|
||||||
|
token=@Token
|
||||||
|
and julianday(created_date, @Timeout) > julianday(CURRENT_TIMESTAMP)",
|
||||||
|
connection);
|
||||||
|
var tokenParam = cmd.CreateParameter();
|
||||||
|
tokenParam.ParameterName = "@Token";
|
||||||
|
tokenParam.Value = token;
|
||||||
|
var timeoutParam = cmd.CreateParameter();
|
||||||
|
timeoutParam.ParameterName = "@Timeout";
|
||||||
|
timeoutParam.Value = $"+{s_lifetime} seconds";
|
||||||
|
cmd.Parameters.Add(tokenParam);
|
||||||
|
cmd.Parameters.Add(timeoutParam);
|
||||||
|
return cmd.ExecuteScalar() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveExpiredTokens() {
|
||||||
|
using var connection = new SqliteConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = new SqliteCommand(
|
||||||
|
@"delete from tokens where
|
||||||
|
julianday(created_date, @Timeout) < julianday(CURRENT_TIMESTAMP)",
|
||||||
|
connection);
|
||||||
|
var timeoutParam = cmd.CreateParameter();
|
||||||
|
timeoutParam.ParameterName = "@Timeout";
|
||||||
|
timeoutParam.Value = $"+{s_lifetime} seconds";
|
||||||
|
cmd.Parameters.Add(timeoutParam);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WebApplication Initialize(string[] args) {
|
||||||
|
Batteries.Init();
|
||||||
|
|
||||||
|
using var connection = new SqliteConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
using (var cmd = new SqliteCommand(
|
||||||
|
@"select 1 from sqlite_master
|
||||||
|
where type='table' and name='credentials'",
|
||||||
|
connection)) {
|
||||||
|
var exists = cmd.ExecuteScalar();
|
||||||
|
if (exists == null) {
|
||||||
|
var schemaPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "schema.sql");
|
||||||
|
if (!File.Exists(schemaPath)) {
|
||||||
|
throw new FileNotFoundException(schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var schema = File.ReadAllText(schemaPath);
|
||||||
|
using var schemaCmd = new SqliteCommand(schema, connection);
|
||||||
|
schemaCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveExpiredTokens();
|
||||||
|
|
||||||
|
// read credentials
|
||||||
|
using (var cmd = new SqliteCommand(
|
||||||
|
@"select id from credentials",
|
||||||
|
connection)) {
|
||||||
|
var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) {
|
||||||
|
var id = reader.GetString(0);
|
||||||
|
var desc = new PublicKeyCredentialDescriptor(
|
||||||
|
Convert.FromBase64String(id));
|
||||||
|
if (desc != null) s_keys.Add(desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Services.AddDistributedMemoryCache();
|
||||||
|
builder.Services.AddSession();
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
}
|
102
src/login.html
Normal file
102
src/login.html
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang='en'>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
function atobarray(sBase64) {
|
||||||
|
const sBinaryString = atob(sBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const aBinaryView = new Uint8Array(sBinaryString.length);
|
||||||
|
Array.prototype.forEach.call(aBinaryView, function (el, idx, arr) {
|
||||||
|
arr[idx] = sBinaryString.charCodeAt(idx);
|
||||||
|
});
|
||||||
|
return aBinaryView;
|
||||||
|
}
|
||||||
|
|
||||||
|
function barraytoa(arrayBuffer) {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configure() {
|
||||||
|
try {
|
||||||
|
const data = await fetch('/auth/new-key', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const json = await data.json();
|
||||||
|
json.challenge = atobarray(json.challenge);
|
||||||
|
json.user.id = atobarray(json.user.id);
|
||||||
|
const cred = await navigator.credentials.create({ publicKey: json });
|
||||||
|
var password = prompt('Administrative Password');
|
||||||
|
const addResp = await fetch('/auth/add-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password,
|
||||||
|
response : {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: barraytoa(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: barraytoa(cred.response.clientDataJSON),
|
||||||
|
attestationObject: barraytoa(cred.response.attestationObject),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
try {
|
||||||
|
const data = await fetch('/auth/key', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const json = await data.json();
|
||||||
|
let result;
|
||||||
|
if (json.allowCredentials !== undefined && json.allowCredentials.length > 0) {
|
||||||
|
json.challenge = atobarray(json.challenge);
|
||||||
|
for (let i = 0; i < json.allowCredentials.length; i++) {
|
||||||
|
json.allowCredentials[i].id =
|
||||||
|
atobarray(json.allowCredentials[i].id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
result = await navigator.credentials.get({ publicKey: json });
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
await configure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetch('/auth/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: result.id,
|
||||||
|
rawId: barraytoa(result.rawId),
|
||||||
|
type: result.type,
|
||||||
|
response: {
|
||||||
|
authenticatorData: barraytoa(result.response.authenticatorData),
|
||||||
|
clientDataJSON: barraytoa(result.response.clientDataJSON),
|
||||||
|
signature: barraytoa(result.response.signature),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const params = await new URLSearchParams(window.location.search);
|
||||||
|
if (params.has('target')) {
|
||||||
|
window.location.href = params.get('target');
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await configure();
|
||||||
|
window.location.href='/auth/login';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
<div id="command"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
src/omnisharp.json
Normal file
8
src/omnisharp.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"RoslynExtensionsOptions": {
|
||||||
|
"enableAnalyzersSupport": true
|
||||||
|
},
|
||||||
|
"FormattingOptions": {
|
||||||
|
"enableEditorConfigSupport": true
|
||||||
|
}
|
||||||
|
}
|
10
src/schema.sql
Normal file
10
src/schema.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
create table credentials (
|
||||||
|
id text primary key,
|
||||||
|
key text not null,
|
||||||
|
created_date datetime not null default CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
create table tokens (
|
||||||
|
token text primary key,
|
||||||
|
created_date datetime not null default CURRENT_TIMESTAMP
|
||||||
|
);
|
24
src/webauthn-proxy.csproj
Normal file
24
src/webauthn-proxy.csproj
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Fido2" Version="3.0.1" />
|
||||||
|
<PackageReference Include="Fido2.Models" Version="3.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SQLite" Version="7.0.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="schema.sql">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="login.html">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
Loading…
Reference in a new issue