A api/login.go => api/login.go +56 -0
@@ 0,0 1,56 @@
+package api
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "tixe/db"
+
+ "github.com/gin-gonic/gin"
+ "github.com/matthewhartstonge/argon2"
+)
+
+type PostInternalLoginDetails struct {
+ Username string `form:"username"`
+ Password string `form:"password"`
+}
+
+func PostInternalLogin(c *gin.Context) {
+ userDetails := &PostInternalLoginDetails{}
+ if err := c.Bind(userDetails); err != nil {
+ log.Print("[tixe/api/iauth/login] Could not bind login details")
+ c.String(http.StatusBadRequest, "could not bind login details")
+ return;
+ }
+
+ // Fetch user
+ var (
+ username string
+ passwordHash string
+ )
+
+ err := db.PgPool.QueryRow(context.Background(), "SELECT username, password FROM users WHERE username = $1", userDetails.Username).Scan(&username, &passwordHash)
+ if err != nil {
+ log.Printf("[tixe/api/iauth/login] WARN: Error querying internal user from database: %s", err.Error())
+ c.String(http.StatusUnauthorized, "incorrect details")
+ return
+ }
+
+ // Verify password
+ ok, err := argon2.VerifyEncoded([]byte(userDetails.Password), []byte(passwordHash))
+ if err != nil {
+ log.Printf("[tixe/api/iauth/login] WARN: Error verifying internal user password: %s", err.Error())
+ c.String(http.StatusUnauthorized, "incorrect details")
+ return
+ }
+
+ if !ok {
+ // Password did not match
+ c.String(http.StatusUnauthorized, "incorrect details")
+ return
+ }
+
+
+
+ c.Redirect(http.StatusTemporaryRedirect, "/")
+}
M db/migrations.go => db/migrations.go +12 -0
@@ 64,5 64,17 @@ func migrations() []string {
ran_timestamp timestamp,
schema_version integer
)`,migrationsTable),
+ `CREATE TABLE users (
+ id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
+ is_internal BOOLEAN NOT NULL DEFAULT FALSE,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ display_name TEXT NOT NULL,
+ username TEXT,
+ password TEXT,
+ oidc_subject TEXT
+ )`,
+ `INSERT INTO
+ users(is_internal, is_admin, display_name, username, password)
+ VALUES(TRUE, TRUE, "admin", "admin", "$argon2id$v=19$m=65536,t=3,p=4$UC+I4MhJyVJG+N2OGPecHQ$ISwMkf1LQh0wUgoP7im7yzT1vKUNDTWVbT828m75woY")`,
}
}
M dev.elv => dev.elv +1 -1
@@ 1,4 1,4 @@
-#!/bin/elvish
+#!/usr/bin/env elvish
fn run {
clear
A flake.lock => flake.lock +27 -0
@@ 0,0 1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1687701825,
+ "narHash": "sha256-aMC9hqsf+4tJL7aJWSdEUurW2TsjxtDcJBwM9Y4FIYM=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "07059ee2fa34f1598758839b9af87eae7f7ae6ea",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
A flake.nix => flake.nix +24 -0
@@ 0,0 1,24 @@
+{
+ description = "tixe";
+ nixConfig.bash-prompt = "nix | tixe> ";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
+ };
+
+ outputs = { self, nixpkgs }:
+ let
+ pkgs = nixpkgs.legacyPackages.x86_64-linux.pkgs;
+ in
+ {
+ devShells.x86_64-linux.default = pkgs.mkShell {
+ name = "tixe env";
+ buildInputs = with pkgs; [
+ go
+ gopls
+
+ nodePackages.tailwindcss
+ ];
+ };
+ };
+}
M go.mod => go.mod +1 -0
@@ 27,6 27,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
+ github.com/matthewhartstonge/argon2 v0.3.2 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
M go.sum => go.sum +2 -0
@@ 54,6 54,8 @@ github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/q
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
+github.com/matthewhartstonge/argon2 v0.3.2 h1:nOsWkxRFIdPueV7Dla/R/5JZRN2NncnCAlMg3ktyv/A=
+github.com/matthewhartstonge/argon2 v0.3.2/go.mod h1:7SVzBdtpqJcIUzzRghzsT8m/DT/kbTDiGifrjZbQ0sA=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
M handlers/auth.go => handlers/auth.go +29 -0
@@ 1,8 1,11 @@
package handlers
import (
+ "context"
+ "log"
"net/http"
"tixe/auth"
+ "tixe/db"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ 41,6 44,32 @@ func AuthCallback(auth *auth.Auth) gin.HandlerFunc {
return
}
+ // Create a user in the database for this OIDC user.
+ // idToken.Subject shooooould be unique and always the same for a user.
+ // I think. Maybe.
+
+ // But first, check if it exists!
+ var oidcSubject string
+ err = db.PgPool.QueryRow(context.Background(), "SELECT oidc_subject FROM users WHERE oidc_subject = $1", idToken.Subject).Scan(&oidcSubject)
+ if err != nil {
+ log.Printf("[tixe/auth] WARN: Failed to query database for oidc user")
+ c.String(http.StatusInternalServerError, err.Error())
+ return
+ }
+ if idToken.Subject == oidcSubject {
+ // Exists, we outta here!
+ c.Redirect(http.StatusTemporaryRedirect, "/")
+ return
+ }
+
+ // Now create it, since it did not exists.
+ _, err = db.PgPool.Exec(context.Background(), "INSERT INTO users(display_name, oidc_subject) VALUES($1, $2)", profile["name"].(string), idToken.Subject)
+ if err != nil {
+ log.Printf("[tixe/auth] WARN: Could not create database entry for oidc user.")
+ c.String(http.StatusInternalServerError, err.Error())
+ return
+ }
+
c.Redirect(http.StatusTemporaryRedirect, "/")
}
}
M static/styles.css => static/styles.css +43 -12
@@ 527,6 527,11 @@ video {
margin-bottom: 3rem;
}
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
.flex {
display: flex;
}
@@ 549,14 554,22 @@ video {
min-width: max-content;
}
-.max-w-4xl {
- max-width: 56rem;
+.min-w-full {
+ min-width: 100%;
}
.max-w-3xl {
max-width: 48rem;
}
+.max-w-4xl {
+ max-width: 56rem;
+}
+
+.max-w-full {
+ max-width: 100%;
+}
+
.grow {
flex-grow: 1;
}
@@ 589,10 602,18 @@ video {
border-radius: 0.375rem;
}
+.rounded {
+ border-radius: 0.25rem;
+}
+
.border-2 {
border-width: 2px;
}
+.border {
+ border-width: 1px;
+}
+
.border-b-2 {
border-bottom-width: 2px;
}
@@ 618,18 639,18 @@ video {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
}
-.from-slate-600 {
- --tw-gradient-from: #475569 var(--tw-gradient-from-position);
- --tw-gradient-to: rgb(71 85 105 / 0) var(--tw-gradient-to-position);
- --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
-}
-
.from-neutral-500 {
--tw-gradient-from: #737373 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(115 115 115 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
+.from-slate-600 {
+ --tw-gradient-from: #475569 var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(71 85 105 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
.from-transparent {
--tw-gradient-from: transparent var(--tw-gradient-from-position);
--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
@@ 641,14 662,14 @@ video {
--tw-gradient-stops: var(--tw-gradient-from), #60a5fa var(--tw-gradient-via-position), var(--tw-gradient-to);
}
-.to-rose-500 {
- --tw-gradient-to: #f43f5e var(--tw-gradient-to-position);
-}
-
.to-neutral-500 {
--tw-gradient-to: #737373 var(--tw-gradient-to-position);
}
+.to-rose-500 {
+ --tw-gradient-to: #f43f5e var(--tw-gradient-to-position);
+}
+
.to-transparent {
--tw-gradient-to: transparent var(--tw-gradient-to-position);
}
@@ 702,6 723,11 @@ video {
color: transparent;
}
+.text-slate-500 {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
+
.opacity-75 {
opacity: 0.75;
}
@@ 740,6 766,11 @@ video {
text-decoration-line: underline;
}
+.focus\:outline-none:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
@media (prefers-color-scheme: dark) {
.dark\:opacity-100 {
opacity: 1;
M template/templates/login.tmpl => template/templates/login.tmpl +20 -3
@@ 7,8 7,25 @@
Liljamo Auth
</a>
<div class="max-w-3xl w-full flex items-center gap-4">
- <div class="grow my-12 h-px border-t-0 bg-transparent bg-gradient-to-r from-transparent to-neutral-500 opacity-75 dark:opacity-100"></div>
- <div class="grow-0">OR</div>
- <div class="grow my-12 h-px border-t-0 bg-transparent bg-gradient-to-r from-neutral-500 to-transparent opacity-75 dark:opacity-100"></div>
+ <div class="grow my-4 h-px border-t-0 bg-transparent bg-gradient-to-r from-transparent to-neutral-500 opacity-75 dark:opacity-100"></div>
+ <div class="grow-0">or</div>
+ <div class="grow my-4 h-px border-t-0 bg-transparent bg-gradient-to-r from-neutral-500 to-transparent opacity-75 dark:opacity-100"></div>
+</div>
+<div>
+ <form class="flex flex-col gap-2 items-center max-w-full" action="/api/iauth/login" method="POST">
+ <div class="flex gap-2 p-2 border rounded min-w-full">
+ <label for="username">Username</label>
+ <input class="text-slate-500 focus:outline-none" type="text" name="username" id="username" placeholder="Username..."/>
+ </div>
+ <div class="flex gap-2 p-2 border rounded min-w-full">
+ <label for="password">Password</label>
+ <input class="text-slate-500 focus:outline-none" type="password" name="password" id="password" placeholder="Password..."/>
+ </div>
+ <button type="submit" class="w-max p-2 border-2
+ rounded-md drop-shadow-md
+ bg-slate-50 hover:bg-slate-200 transition-colors">
+ Login
+ </button>
+ </form>
</div>
{{ end }}
M tixe.go => tixe.go +6 -0
@@ 64,6 64,12 @@ func setupRouter(auth *auth.Auth) *gin.Engine {
{
apiRoute.GET("/", api.Root)
apiRoute.GET("/ping", ping)
+
+ // internal auth route
+ iauth := apiRoute.Group("/iauth")
+ {
+ iauth.POST("/login", api.PostInternalLogin)
+ }
}
}