Basic login.

This commit is contained in:
2023-08-01 23:19:14 +02:00
parent 79af3538bc
commit 8261a31679
12 changed files with 567 additions and 110 deletions

View File

@@ -8,7 +8,10 @@
"name": "migo",
"version": "0.0.0",
"dependencies": {
"axios": "^1.4.0",
"js-cookie": "^3.0.5",
"pinia": "^2.1.3",
"qs": "^6.11.2",
"sass": "^1.63.6",
"vue": "^3.3.4",
"vue-router": "^4.2.2"
@@ -1103,6 +1106,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/available-typed-arrays": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
@@ -1115,6 +1123,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1160,7 +1178,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
@@ -1249,6 +1266,17 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1331,6 +1359,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -1817,6 +1853,25 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -1826,6 +1881,19 @@
"is-callable": "^1.1.3"
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -1848,8 +1916,7 @@
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/function.prototype.name": {
"version": "1.1.5",
@@ -1882,7 +1949,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz",
"integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
@@ -2019,7 +2085,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.1"
},
@@ -2061,7 +2126,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz",
"integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -2073,7 +2137,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -2442,6 +2505,14 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"engines": {
"node": ">=14"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -2581,6 +2652,25 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2848,7 +2938,6 @@
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
"integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -3139,6 +3228,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/punycode": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
@@ -3148,6 +3242,20 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
"integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==",
"dependencies": {
"side-channel": "^1.0.4"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3409,7 +3517,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",

View File

@@ -11,7 +11,10 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"axios": "^1.4.0",
"js-cookie": "^3.0.5",
"pinia": "^2.1.3",
"qs": "^6.11.2",
"sass": "^1.63.6",
"vue": "^3.3.4",
"vue-router": "^4.2.2"

View File

@@ -1,4 +1,51 @@
* {
box-sizing: border-box;
}
html, body {
padding: 0;
margin: 0;
font-family: sans-serif;
font-size: 18px;
}
fieldset {
border-width: 0;
padding: 10px 0;
margin: 0;
}
label {
display: block;
font-size: 12px;
color: #767676;
}
input, textarea {
width: 100%;
border: 1px solid #ed6673;
border-radius: 3px;
padding: 6px;
}
input[type~="submit"], button {
width: auto;
border-width: 0;
background: #ed6673;
color: #fff;
padding: 6px 12px;
cursor: pointer;
}
h1 {
margin: 0 0 20px 0;
font-size: 32px;
}
.login {
max-width: 350px;
margin: 200px auto;
padding: 20px;
border: 1px solid #c5c5c5;
border-radius: 3px;
}

View File

@@ -0,0 +1,8 @@
export interface Validation {
[key: string]: string
}
export interface APIError {
validation: Validation
error: string[]
}

View File

@@ -0,0 +1,85 @@
import {AxiosError, AxiosInstance} from "axios";
import type {APIError} from "@/repositories/APIError";
import axios from "@/repositories/axios";
export class Repository {
protected axios: AxiosInstance = axios;
private controller: Map<string, AbortController> = new Map;
protected async performGet<T>(endpoint: string, params: unknown, defaultValue: T, disableCancellation?: boolean): Promise<T | APIError> {
try {
let controller = null;
if (!disableCancellation) {
controller = this.cancel(endpoint);
}
const resp = await this.axios.get<T>(endpoint, {
params,
signal: controller?.signal
});
return Promise.resolve<T>(resp.data || defaultValue);
} catch (e) {
return this.handleException<T>(e, defaultValue);
} finally {
if (!disableCancellation) {
this.clearCancel(endpoint);
}
}
}
protected async performPost<T>(endpoint: string, data: unknown): Promise<T | APIError> {
try {
const resp = await this.axios.post<T>(endpoint, data);
return Promise.resolve<T>(resp.data);
} catch (e) {
const err = e as AxiosError;
return Promise.reject<APIError>(err);
}
}
protected async performPut<T>(endpoint: string, data: unknown): Promise<T | APIError> {
try {
const resp = await this.axios.put<T>(endpoint, data);
return Promise.resolve<T>(resp.data);
} catch (e) {
const err = e as AxiosError;
return Promise.reject<APIError>(err);
}
}
protected async performDelete(endpoint: string, params: unknown): Promise<APIError | null> {
try {
await this.axios.delete<null>(endpoint, {params});
return Promise.resolve<null>(null);
} catch (e) {
return Promise.reject<APIError>(e);
}
}
protected handleException<T>(e: unknown, defaultValue: T): Promise<T | APIError> {
const err = e as AxiosError;
if (err.code && err.code === "ERR_CANCELED") {
return Promise.resolve<T>(defaultValue);
}
return Promise.reject<APIError>(err);
}
private cancel(name: string): AbortController {
const controller = this.controller.get(name);
if (controller) {
controller.abort();
}
const newController = new AbortController();
this.controller.set(name, newController);
return newController;
}
private clearCancel(name: string) {
this.controller.delete(name);
}
}

View File

@@ -0,0 +1,13 @@
import {Repository} from "@/repositories/Repository";
import type {APIError} from "@/repositories/APIError";
export interface TokenResponse {
access_token: string
expires_at: Date
}
export const UserRepository = new class extends Repository {
async login(username: string, password: string): Promise<TokenResponse | APIError> {
return this.performPost<TokenResponse>("/login", {username, password});
}
}

View File

@@ -0,0 +1,74 @@
import axios, {AxiosResponse} from "axios";
import Cookies from "js-cookie";
import qs from "qs";
import {APIError} from "@/repositories/APIError";
let refresh: Promise<void | AxiosResponse> | undefined;
const instance = axios.create({
baseURL: "/api/v1",
timeout: 10000,
paramsSerializer: {
serialize: params => qs.stringify(params, {arrayFormat: "repeat"})
}
});
instance.interceptors.request.use(config => {
const accessToken = Cookies.get("access_token");
if (accessToken && config && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return Promise.resolve(config);
}, err => {
return Promise.reject(err);
});
instance.interceptors.response.use(resp => {
return resp;
}, err => {
if (err.code && err.code === "ERR_CANCELED") {
return Promise.reject(err);
}
if (err.response.status === 401 && err.request.path !== "/refresh") {
return refreshToken().then(() => {
return instance.request(err.config);
}).catch(e => {
console.error(e);
//removeCookies(); TODO
localStorage.clear();
if (e.request.path !== "/login") {
location.href = "/login";
}
});
}
return Promise.reject(err.response.data as APIError);
});
async function refreshToken() {
if (refresh) {
return refresh;
}
const refresh_token = Cookies.get("refresh_token");
refresh = instance.get("/refresh", {params: {refresh_token}}).then(({data: {access_token}}) => {
const now = new Date();
const expires = Date.UTC(now.getUTCFullYear()+1, now.getUTCMonth(), now.getUTCDate());
Cookies.set("access_token", access_token, {
expires: new Date(expires),
secure: true,
domain: "localhost", //getCookieDomain(), TODO
path: "/",
sameSite: "strict"
});
refresh = undefined;
return Promise.resolve();
});
return refresh;
}
export default instance;

View File

@@ -1,9 +1,44 @@
<template>
<h1>Sign In</h1>
<div class="login">
<h1>Sign In</h1>
<form v-on:submit.prevent="login">
<fieldset>
<label for="username">Username</label>
<input type="text" name="username" id="username" v-model="username" />
</fieldset>
<fieldset>
<label for="password">Password</label>
<input type="password" name="password" id="password" v-model="password" />
</fieldset>
<input type="submit" value="Sign In" />
</form>
</div>
</template>
<script lang="ts">
import {defineComponent} from "vue";
import {defineComponent, ref} from "vue";
import {UserRepository} from "@/repositories/UserRepository";
export default defineComponent({});
export default defineComponent({
setup() {
const username = ref("");
const password = ref("");
async function login() {
try {
const token = await UserRepository.login(username.value, password.value);
console.log(token);
} catch (e) {
// TODO
console.error(e);
}
}
return {
username,
password,
login
};
}
});
</script>

View File

@@ -2,11 +2,7 @@ package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"embed"
"encoding/pem"
"github.com/Kugelschieber/migo/api"
"github.com/Kugelschieber/migo/db"
"github.com/go-chi/chi/v5"
@@ -30,106 +26,21 @@ var (
//go:embed admin/dist/assets
assets embed.FS
jwtAuth *jwtauth.JWTAuth
)
func init() {
generateRSAKeys()
/*jwtAuth = jwtauth.New("RS256", loadRSAPrivateKey(), loadRSAPublicKey())
_, tokenString, err := jwtAuth.Encode(map[string]interface{}{"test": 42})*/
}
func generateRSAKeys() {
err := os.Mkdir("secrets", 0755)
if os.IsExist(err) {
return
} else if err != nil {
log.Fatalf("Error creating secrets directory: %v", err)
}
key, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
log.Fatalf("Error generating RSA key: %v", err)
}
pub := key.Public()
keyPEM := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
},
)
pubPEM := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(pub.(*rsa.PublicKey)),
},
)
if err := os.WriteFile("secrets/jwt.rsa", keyPEM, 0700); err != nil {
log.Fatalf("Error writing private RSA key: %v", err)
}
if err := os.WriteFile("secrets/jwt.rsa.pub", pubPEM, 0755); err != nil {
log.Fatalf("Error writing public RSA key: %v", err)
}
}
func loadRSAPublicKey() *rsa.PublicKey {
data, err := os.ReadFile("secrets/jwt.rsa.pub")
if err != nil {
log.Fatalf("Error loading RSA key: %v", err)
}
block, _ := pem.Decode(data)
if block == nil {
log.Fatalf("Error decoding RSA key: %v", err)
}
key, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
log.Fatalf("Error parsing RSA key: %v", err)
}
return key
}
func loadRSAPrivateKey() *rsa.PrivateKey {
data, err := os.ReadFile("secrets/jwt.rsa")
if err != nil {
log.Fatalf("Error loading RSA key: %v", err)
}
block, _ := pem.Decode(data)
if block == nil {
log.Fatalf("Error decoding RSA key: %v", err)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatalf("Error parsing RSA key: %v", err)
}
return key
}
func main() {
if err := db.Init(); err != nil {
log.Fatalf("Error initializing database: %v", err)
}
defer db.Close()
api.InitJWT()
dev := os.Getenv("MIGO_DEV") != ""
if dev {
log.Println("Running in development mode")
}
router := chi.NewRouter()
router.Use(middleware.Recoverer)
router.Use(middleware.Compress(5))
@@ -140,11 +51,12 @@ func main() {
AllowCredentials: true,
MaxAge: 86400,
}))
router.Post("/api/v1/login", api.Login)
router.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(jwtAuth))
r.Use(jwtauth.Verifier(api.GetJWTAuth()))
r.Use(jwtauth.Authenticator)
r.Route("/api/v1", func(r chi.Router) {
r.Get("/debug", api.DebugHandler)
r.Get("/debug", api.Debug)
})
})
router.Handle("/admin", http.RedirectHandler("/admin/", http.StatusFound))