From 8261a31679fca29c285a169b2f7c829e2a878660 Mon Sep 17 00:00:00 2001 From: Marvin Blum Date: Tue, 1 Aug 2023 23:19:14 +0200 Subject: [PATCH] Basic login. --- api/auth.go | 131 +++++++++++++++++++ api/debug.go | 2 +- api/util.go | 42 ++++++ cmd/admin/package-lock.json | 125 ++++++++++++++++-- cmd/admin/package.json | 3 + cmd/admin/src/assets/main.scss | 47 +++++++ cmd/admin/src/repositories/APIError.ts | 8 ++ cmd/admin/src/repositories/Repository.ts | 85 ++++++++++++ cmd/admin/src/repositories/UserRepository.ts | 13 ++ cmd/admin/src/repositories/axios.ts | 74 +++++++++++ cmd/admin/src/views/Login.vue | 41 +++++- cmd/main.go | 106 ++------------- 12 files changed, 567 insertions(+), 110 deletions(-) create mode 100644 api/auth.go create mode 100644 api/util.go create mode 100644 cmd/admin/src/repositories/APIError.ts create mode 100644 cmd/admin/src/repositories/Repository.ts create mode 100644 cmd/admin/src/repositories/UserRepository.ts create mode 100644 cmd/admin/src/repositories/axios.ts diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..2959520 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,131 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "github.com/go-chi/jwtauth/v5" + "log" + "net/http" + "os" + "time" +) + +var ( + jwtAuth *jwtauth.JWTAuth +) + +// GetJWTAuth returns the JWT authorizer. +func GetJWTAuth() *jwtauth.JWTAuth { + return jwtAuth +} + +// InitJWT initializes JWT authentication. +func InitJWT() { + generateRSAKeys() + jwtAuth = jwtauth.New("RS256", loadRSAPrivateKey(), loadRSAPublicKey()) +} + +func Login(w http.ResponseWriter, r *http.Request) { + req := struct { + Username string `json:"username"` + Password string `json:"password"` + }{} + + if err := decodeJSON(w, r, &req); err != nil { + return + } + + // TODO + t, tokenString, _ := jwtAuth.Encode(map[string]interface{}{"username": req.Username}) + encodeJSON(w, struct { + AccessToken string `json:"access_token"` + ExpiresAt time.Time `json:"expires_at"` + }{ + tokenString, + t.Expiration(), + }) +} + +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 +} diff --git a/api/debug.go b/api/debug.go index 77d7784..0111efe 100644 --- a/api/debug.go +++ b/api/debug.go @@ -7,7 +7,7 @@ import ( "net/http" ) -func DebugHandler(w http.ResponseWriter, r *http.Request) { +func Debug(w http.ResponseWriter, r *http.Request) { _, claims, err := jwtauth.FromContext(r.Context()) if err != nil { diff --git a/api/util.go b/api/util.go new file mode 100644 index 0000000..b433e4f --- /dev/null +++ b/api/util.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "syscall" +) + +type apiError struct { + Validation map[string]string `json:"validation"` + Err []error `json:"error"` +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, req any) error { + body, err := io.ReadAll(r.Body) + + if err != nil { + return err + } + + if err := json.Unmarshal(body, &req); err != nil { + return err + } + + return nil +} + +func encodeJSON(w http.ResponseWriter, resp any) { + respBody, err := json.Marshal(resp) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Printf("Error marshalling response: %v", err) + return + } + + if _, err := w.Write(respBody); err != nil && err != syscall.EPIPE { + log.Printf("Error sending response: %v", err) + } +} diff --git a/cmd/admin/package-lock.json b/cmd/admin/package-lock.json index 230082d..23ce74f 100644 --- a/cmd/admin/package-lock.json +++ b/cmd/admin/package-lock.json @@ -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", diff --git a/cmd/admin/package.json b/cmd/admin/package.json index 1628b75..002951b 100644 --- a/cmd/admin/package.json +++ b/cmd/admin/package.json @@ -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" diff --git a/cmd/admin/src/assets/main.scss b/cmd/admin/src/assets/main.scss index b441eae..b28734c 100644 --- a/cmd/admin/src/assets/main.scss +++ b/cmd/admin/src/assets/main.scss @@ -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; } diff --git a/cmd/admin/src/repositories/APIError.ts b/cmd/admin/src/repositories/APIError.ts new file mode 100644 index 0000000..09ea6e5 --- /dev/null +++ b/cmd/admin/src/repositories/APIError.ts @@ -0,0 +1,8 @@ +export interface Validation { + [key: string]: string +} + +export interface APIError { + validation: Validation + error: string[] +} diff --git a/cmd/admin/src/repositories/Repository.ts b/cmd/admin/src/repositories/Repository.ts new file mode 100644 index 0000000..99fe6bf --- /dev/null +++ b/cmd/admin/src/repositories/Repository.ts @@ -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 = new Map; + + protected async performGet(endpoint: string, params: unknown, defaultValue: T, disableCancellation?: boolean): Promise { + try { + let controller = null; + + if (!disableCancellation) { + controller = this.cancel(endpoint); + } + + const resp = await this.axios.get(endpoint, { + params, + signal: controller?.signal + }); + return Promise.resolve(resp.data || defaultValue); + } catch (e) { + return this.handleException(e, defaultValue); + } finally { + if (!disableCancellation) { + this.clearCancel(endpoint); + } + } + } + + protected async performPost(endpoint: string, data: unknown): Promise { + try { + const resp = await this.axios.post(endpoint, data); + return Promise.resolve(resp.data); + } catch (e) { + const err = e as AxiosError; + return Promise.reject(err); + } + } + + protected async performPut(endpoint: string, data: unknown): Promise { + try { + const resp = await this.axios.put(endpoint, data); + return Promise.resolve(resp.data); + } catch (e) { + const err = e as AxiosError; + return Promise.reject(err); + } + } + + protected async performDelete(endpoint: string, params: unknown): Promise { + try { + await this.axios.delete(endpoint, {params}); + return Promise.resolve(null); + } catch (e) { + return Promise.reject(e); + } + } + + protected handleException(e: unknown, defaultValue: T): Promise { + const err = e as AxiosError; + + if (err.code && err.code === "ERR_CANCELED") { + return Promise.resolve(defaultValue); + } + + return Promise.reject(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); + } +} diff --git a/cmd/admin/src/repositories/UserRepository.ts b/cmd/admin/src/repositories/UserRepository.ts new file mode 100644 index 0000000..dd1ef52 --- /dev/null +++ b/cmd/admin/src/repositories/UserRepository.ts @@ -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 { + return this.performPost("/login", {username, password}); + } +} diff --git a/cmd/admin/src/repositories/axios.ts b/cmd/admin/src/repositories/axios.ts new file mode 100644 index 0000000..f434803 --- /dev/null +++ b/cmd/admin/src/repositories/axios.ts @@ -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 | 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; diff --git a/cmd/admin/src/views/Login.vue b/cmd/admin/src/views/Login.vue index e2c45d4..984399e 100644 --- a/cmd/admin/src/views/Login.vue +++ b/cmd/admin/src/views/Login.vue @@ -1,9 +1,44 @@ diff --git a/cmd/main.go b/cmd/main.go index feb361e..65851cb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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))