feat: added users db, login, register, logout

This commit is contained in:
Andre Henriques 2023-09-19 13:39:59 +01:00
parent b22d64a568
commit f2bf34b931
15 changed files with 586 additions and 50 deletions

6
db.go Normal file
View File

@ -0,0 +1,6 @@
package main
import (
)

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
version: '3.1'
services:
db:
image: docker.andr3h3nriqu3s.com/services/postgres
restart: always
environment:
POSTGRES_PASSWORD: verysafepassword
ports:
- "5432:5432"

5
go.mod
View File

@ -1,3 +1,8 @@
module andr3h3nriqu3s.com/m
go 1.20
require (
github.com/lib/pq v1.10.9 // indirect
golang.org/x/crypto v0.13.0 // indirect
)

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

View File

@ -1,11 +1,15 @@
package main
import (
"database/sql"
"errors"
"fmt"
"html/template"
"io"
"log"
"net/http"
"strings"
"time"
)
func baseLoadTemplate(base string, path string) (*template.Template, any) {
@ -74,6 +78,8 @@ func LoadHtml(writer http.ResponseWriter, path string, data interface{}) {
}
}
type AnyMap = map[string]interface{}
type Error struct {
code int
msg *string
@ -90,10 +96,10 @@ const (
func LoadBasedOnAnswer(ans AnswerType, w http.ResponseWriter, path string, data map[string]interface{}) {
if ans == NORMAL {
LoadView(w, path, nil)
LoadView(w, path, data)
return
} else if ans == HTML {
LoadHtml(w, path, nil)
LoadHtml(w, path, data)
return
} else if ans == HTMLFULL {
if data == nil {
@ -116,26 +122,43 @@ func LoadBasedOnAnswer(ans AnswerType, w http.ResponseWriter, path string, data
type HandleFunc struct {
path string
mode AnswerType
fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error
fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error
}
type Handler interface {
New()
Startup()
Get(fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error)
Post(fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error)
Get(fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error)
Post(fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error)
}
type Handle struct {
db *sql.DB
gets []HandleFunc
posts []HandleFunc
}
func handleError(err *Error, answerType AnswerType, w http.ResponseWriter) {
func decodeBody(r *http.Request) (string, *Error) {
body, err := io.ReadAll(r.Body)
if err == nil {
return "", &Error{code: http.StatusBadRequest}
}
return string(body[:]), nil
}
func handleError(err *Error, w http.ResponseWriter, context *Context) {
data := context.toMap()
if err != nil {
w.WriteHeader(err.code)
if err.code == 404 {
LoadBasedOnAnswer(answerType, w, "404.html", nil)
if err.code == http.StatusNotFound {
LoadBasedOnAnswer(context.Mode, w, "404.html", data)
return
}
if err.code == http.StatusBadRequest {
LoadBasedOnAnswer(context.Mode, w, "400.html", data)
return
}
if err.msg != nil {
@ -144,7 +167,7 @@ func handleError(err *Error, answerType AnswerType, w http.ResponseWriter) {
}
}
func (x *Handle) Get(path string, fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error) {
func (x *Handle) Get(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler :=
HandleFunc{
fn: fn,
@ -155,7 +178,7 @@ func (x *Handle) Get(path string, fn func(mode AnswerType, w http.ResponseWriter
x.gets = append(x.gets, nhandler)
}
func (x *Handle) GetHTML(path string, fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error) {
func (x *Handle) GetHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler :=
HandleFunc{
fn: fn,
@ -166,7 +189,7 @@ func (x *Handle) GetHTML(path string, fn func(mode AnswerType, w http.ResponseWr
x.gets = append(x.gets, nhandler)
}
func (x *Handle) GetJSON(path string, fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error) {
func (x *Handle) GetJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler :=
HandleFunc{
fn: fn,
@ -177,7 +200,7 @@ func (x *Handle) GetJSON(path string, fn func(mode AnswerType, w http.ResponseWr
x.gets = append(x.gets, nhandler)
}
func (x *Handle) Post(path string, fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error) {
func (x *Handle) Post(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler :=
HandleFunc{
fn: fn,
@ -188,7 +211,7 @@ func (x *Handle) Post(path string, fn func(mode AnswerType, w http.ResponseWrite
x.posts = append(x.posts, nhandler)
}
func (x *Handle) PostHTML(path string, fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error) {
func (x *Handle) PostHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler :=
HandleFunc{
fn: fn,
@ -199,7 +222,7 @@ func (x *Handle) PostHTML(path string, fn func(mode AnswerType, w http.ResponseW
x.posts = append(x.posts, nhandler)
}
func (x *Handle) PostJSON(path string, fn func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error) {
func (x *Handle) PostJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler :=
HandleFunc{
fn: fn,
@ -210,43 +233,130 @@ func (x *Handle) PostJSON(path string, fn func(mode AnswerType, w http.ResponseW
x.posts = append(x.posts, nhandler)
}
func (x *Handle) handleGets(ans AnswerType, w http.ResponseWriter, r *http.Request) {
func (x *Handle) handleGets(w http.ResponseWriter, r *http.Request, context *Context) {
for _, s := range x.gets {
fmt.Printf("target: %s, paths: %s\n", s.path, r.URL.Path)
if s.path == r.URL.Path && ans&s.mode != 0 {
s.fn(ans, w, r)
if s.path == r.URL.Path && context.Mode&s.mode != 0 {
handleError(s.fn(w, r, context), w, context)
return
}
}
if context.Mode != HTMLFULL {
w.WriteHeader(http.StatusNotFound)
LoadBasedOnAnswer(ans, w, "404.html", nil)
}
LoadBasedOnAnswer(context.Mode, w, "404.html", map[string]interface{}{
"context": context,
})
}
func (x *Handle) handlePosts(ans AnswerType, w http.ResponseWriter, r *http.Request) {
func (x *Handle) handlePosts(w http.ResponseWriter, r *http.Request, context *Context) {
for _, s := range x.posts {
if s.path == r.URL.Path && ans&s.mode != 0 {
s.fn(ans, w, r)
if s.path == r.URL.Path && context.Mode&s.mode != 0 {
handleError(s.fn(w, r, context), w, context)
return
}
}
if context.Mode != HTMLFULL {
w.WriteHeader(http.StatusNotFound)
LoadBasedOnAnswer(ans, w, "404.html", nil)
}
LoadBasedOnAnswer(context.Mode, w, "404.html", map[string]interface{}{
"context": context,
})
}
func AnswerTemplate(path string, data interface{}) func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error {
return func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error {
LoadBasedOnAnswer(mode, w, path, nil)
func AnswerTemplate(path string, data AnyMap) func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
return func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if data == nil {
LoadBasedOnAnswer(c.Mode, w, path, c.toMap())
} else {
LoadBasedOnAnswer(c.Mode, w, path, c.addMap(data))
}
return nil
}
}
func NewHandler() *Handle {
type Context struct {
Token *string
User *User
Mode AnswerType
}
x := &Handle{}
func (c Context) addMap(m AnyMap) AnyMap {
m["Context"] = c;
return m;
}
func (c *Context) toMap() map[string]interface{} {
return map[string]interface{}{
"Context": c,
}
}
func (c *Context) requireAuth(w http.ResponseWriter, r *http.Request) bool {
if c.User == nil {
return true;
}
return false;
}
var LogoffError = errors.New("Invalid token!")
func (x Handle) createContext(mode AnswerType, r *http.Request) (*Context, error) {
var token *string
for _, r := range r.Cookies() {
if r.Name == "auth" {
token = &r.Value
}
}
if token == nil {
return &Context{
Mode: mode,
}, nil
}
user, err := userFromToken(x.db, *token)
if err != nil {
return nil, errors.Join(err, LogoffError)
}
return &Context{token, user, mode}, nil
}
func logoff(mode AnswerType, w http.ResponseWriter, r *http.Request) {
// Delete cookie
cookie := &http.Cookie{
Name: "auth",
Value: "",
Expires: time.Unix(0, 0),
}
http.SetCookie(w, cookie)
// Setup response
w.Header().Set("Location", "/login")
if mode == JSON {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("\"Bye Bye\""));
return
}
if mode & (HTMLFULL | HTML) != 0 {
w.WriteHeader(http.StatusUnauthorized);
w.Write([]byte("Bye Bye"));
} else {
w.WriteHeader(http.StatusSeeOther);
}
}
func NewHandler(db *sql.DB) *Handle {
var gets []HandleFunc
var posts []HandleFunc
x := &Handle{ db, gets, posts, }
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Decide answertype
ans := NORMAL
if r.Header.Get("Request-Type") == "htmlfull" {
ans = HTMLFULL
}
@ -255,12 +365,19 @@ func NewHandler() *Handle {
}
//TODO JSON
//Login state
context, err := x.createContext(ans, r)
if err != nil {
logoff(ans, w, r)
return
}
if r.Method == "GET" {
x.handleGets(ans, w, r)
x.handleGets(w, r, context)
return
}
if r.Method == "POST" {
x.handlePosts(ans, w, r)
x.handlePosts(w, r, context)
return
}
panic("TODO handle: " + r.Method)

33
main.go
View File

@ -2,25 +2,36 @@ package main
import (
"fmt"
"net/http"
"database/sql"
_ "github.com/lib/pq"
)
const (
host = "localhost"
port = 5432
user = "postgres"
password = "verysafepassword"
dbname = "aistuff"
)
func main() {
psqlInfo := fmt.Sprintf("host=%s port=%d user=%s "+
"password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db, err := sql.Open("postgres", psqlInfo)
if err != nil {
panic(err)
}
defer db.Close()
fmt.Println("Starting server on :8000!")
handle := NewHandler()
handle := NewHandler(db)
handle.GetHTML("/", AnswerTemplate("index.html", nil))
handle.GetHTML("/login", AnswerTemplate("login.html", nil))
handle.Post("/login", func(mode AnswerType, w http.ResponseWriter, r *http.Request) *Error {
if mode == JSON {
return &Error{code: 404}
}
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusSeeOther)
return nil
})
usersEndpints(db, handle)
handle.Startup()
}

1
sql/base.sql Normal file
View File

@ -0,0 +1 @@
CREATE DATABASE aistuff;

22
sql/user.sql Normal file
View File

@ -0,0 +1,22 @@
-- drop table if exists tokens;
-- drop table if exists users;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
user_type integer default 0,
username varchar (120) not null,
email varchar (120) not null,
salt char (8) not null,
password char (60) not null,
created_on timestamp default current_timestamp,
updated_at timestamp default current_timestamp,
lastlogin_at timestamp default current_timestamp
);
--drop table if exists tokens;
create table if not exists tokens (
token varchar (120) primary key,
user_id uuid references users (id) on delete cascade,
time_to_live integer default 86400,
emit_day timestamp default current_timestamp
);

246
users.go Normal file
View File

@ -0,0 +1,246 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
id string
username string
email string
user_type int
}
var ErrUserNotFound = errors.New("User Not found")
func userFromToken(db *sql.DB, token string) (*User, error) {
row, err := db.Query("select users.id, users.username, users.email, users.user_type from users inner join tokens on tokens.user_id = users.id where tokens.token = $1;", token)
if err != nil {
return nil, err
}
var id string
var username string
var email string
var user_type int
if !row.Next() {
return nil, ErrUserNotFound
}
err = row.Scan(&id, &username, &email, &user_type)
if err != nil {
return nil, err
}
return &User{id, username, email, user_type}, nil
}
func generateSalt() string {
salt := make([]byte, 4)
_, err := io.ReadFull(rand.Reader, salt)
if err != nil {
panic("TODO handle this better")
}
return hex.EncodeToString(salt)
}
func hashPassword(password string, salt string) (string, error) {
bytes_salt, err := hex.DecodeString(salt)
if err != nil {
return "", err
}
bytes, err := bcrypt.GenerateFromPassword(append([]byte(password), bytes_salt...), 14)
return string(bytes), err
}
func genToken() string {
token := make([]byte, 60)
_, err := io.ReadFull(rand.Reader, token)
if err != nil {
panic("TODO handle this better")
}
return hex.EncodeToString(token)
}
func generateToken(db *sql.DB, email string, password string) (string, bool) {
row, err := db.Query("select id, salt, password from users where email = $1;", email)
if err != nil || !row.Next() {
return "", false
}
var db_id string
var db_salt string
var db_password string
err = row.Scan(&db_id, &db_salt, &db_password)
if err != nil {
return "", false
}
bytes_salt, err := hex.DecodeString(db_salt)
if err != nil {
panic("TODO handle better! Somethign is wrong with salt being stored in the database")
}
if bcrypt.CompareHashAndPassword([]byte(db_password), append([]byte(password), bytes_salt...)) != nil {
return "", false
}
token := genToken()
_, err = db.Exec("insert into tokens (user_id, token) values ($1, $2);", db_id, token)
if err != nil {
return "", false
}
return token, true
}
func usersEndpints(db *sql.DB, handle *Handle) {
handle.GetHTML("/login", AnswerTemplate("login.html", nil))
handle.Post("/login", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON {
fmt.Println("Handle JSON")
return &Error{code: 404}
}
r.ParseForm()
f := r.Form
if checkEmpty(f, "email") || checkEmpty(f, "password") {
LoadBasedOnAnswer(c.Mode, w, "login.html", c.addMap(AnyMap{
"Submited": true,
}))
return nil
}
email := f.Get("email")
password := f.Get("password")
// TODO Give this to the generateToken function
expiration := time.Now().Add(24 * time.Hour)
token, login := generateToken(db, email, password)
if !login {
LoadBasedOnAnswer(c.Mode, w, "login.html", c.addMap(AnyMap{
"Submited": true,
"NoUserOrPassword": true,
"Email": email,
}))
return nil
}
cookie := &http.Cookie{Name: "auth", Value: token, HttpOnly: false, Expires: expiration}
http.SetCookie(w, cookie)
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusSeeOther)
return nil
})
handle.GetHTML("/register", AnswerTemplate("register.html", nil))
handle.Post("/register", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON {
return &Error{code: http.StatusNotFound}
}
r.ParseForm()
f := r.Form
if checkEmpty(f, "email") || checkEmpty(f, "password") || checkEmpty(f, "username") {
LoadBasedOnAnswer(c.Mode, w, "register.html", AnyMap{
"Submited": true,
})
return nil
}
email := f.Get("email")
username := f.Get("username")
password := f.Get("password")
rows, err := db.Query("select username, email from users where username=$1 or email=$2;", username, email)
if err != nil {
panic("TODO handle this")
}
defer rows.Close()
if rows.Next() {
var db_username string
var db_email string
err = rows.Scan(&db_username, &db_email)
if err != nil {
panic("TODO handle this better")
}
LoadBasedOnAnswer(c.Mode, w, "register.html", AnyMap{
"Submited": true,
"Email": email,
"Username": username,
"EmailError": db_email == email,
"UserError": db_username == username,
})
return nil
}
if len([]byte(password)) > 68 {
LoadBasedOnAnswer(c.Mode, w, "register.html", AnyMap{
"Submited": true,
"Email": email,
"Username": username,
"PasswordToLong": true,
})
return nil
}
salt := generateSalt()
hash_password, err := hashPassword(password, salt)
if err != nil {
return &Error{
code: http.StatusInternalServerError,
}
}
_, err = db.Exec("insert into users (username, email, salt, password) values ($1, $2, $3, $4);", username, email, salt, hash_password)
if err != nil {
return &Error{
code: http.StatusInternalServerError,
}
}
// TODO Give this to the generateToken function
expiration := time.Now().Add(24 * time.Hour)
token, login := generateToken(db, email, password)
if !login {
msg := "Login failed"
return &Error{
code: http.StatusInternalServerError,
msg: &msg,
}
}
cookie := &http.Cookie{Name: "auth", Value: token, HttpOnly: false, Expires: expiration}
http.SetCookie(w, cookie)
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusSeeOther)
return nil
})
handle.Get("/logout", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON {
panic("TODO handle json")
}
logoff(c.Mode, w, r)
return nil
})
}

7
utils.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "net/url"
func checkEmpty(f url.Values, path string) bool {
return !f.Has(path) || f.Get(path) == ""
}

View File

@ -5,6 +5,10 @@ function load() {
});
}
}
window.onload = load;
htmx.on('htmx:afterSwap', load);
htmx.on('htmx:beforeSwap', (env) => {
if (env.detail.xhr.status === 401) {
window.location = "/login"
}
});

View File

@ -6,18 +6,27 @@
<h1>
Login
</h1>
<form method="post" action="/login" >
<form method="post" action="/login" {{if .Submited}}class="submitted"{{end}} >
<fieldset>
<label for="email">Email</label>
<input type="email" required name="email" />
<input type="email" required name="email" {{if .Email}} value="{{.Email}}" {{end}} />
</fieldset>
<fieldset>
<label for="password">Password</label>
<input required name="password" type="password" />
{{if .NoUserOrPassword}}
<span class="form-msg error">
Either the password or the email are incorrect
</span>
{{end}}
</fieldset>
<button>
Login
</button>
<div class="spacer"></div>
<a class="simple-link text-center w100 spacer" hx-get="/register" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Register
</a>
</form>
</div>
</div>

View File

@ -1,10 +1,24 @@
<nav>
<ul>
<div class="expand"></div>
<li>
<a hx-get="/" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Index
</a>
</li>
<li class="expand"></li>
{{ .context }}
{{ if .Context.User }}
<li>
<a hx-get="/logout" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Logout
</a>
</li>
{{else}}
<li>
<a hx-get="/login" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Login
</a>
</li>
{{end}}
</ul>
</nav>

49
views/register.html Normal file
View File

@ -0,0 +1,49 @@
{{define "title"}}
Register : AI Stuff
{{end}}
{{define "mainbody"}}
<div class="login-page">
<div>
<h1>
Register
</h1>
<form method="post" action="/register" {{if .Submited}}class="submitted"{{end}} >
<fieldset>
<label for="username">Username</label>
<input required name="username" value="{{.Username}}" />
{{if .UserError}}
<span class="form-msg error">
Username already in use
</span>
{{end}}
</fieldset>
<fieldset>
<label for="email">Email</label>
<input type="email" required name="email" value="{{.Email}}" />
{{if .EmailError}}
<span class="form-msg error">
Email already in use
</span>
{{end}}
</fieldset>
<fieldset>
<label for="password">Password</label>
<input required name="password" type="password" />
{{if .PasswordToLong}}
<span class="form-msg error">
Password is to long
</span>
{{end}}
</fieldset>
<button>
Register
</button>
<div class="spacer"></div>
<a class="simple-link text-center w100" hx-get="/login" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Login
</a>
</form>
</div>
</div>
{{end}}

View File

@ -18,6 +18,20 @@ body {
padding: 0;
}
.w100 {
width: 100%;
display: block;
}
.text-center {
text-align: center;
}
.simple-link {
color: var(--sec);
text-decoration: none;
}
/* Nav bar */
nav {
@ -70,6 +84,7 @@ nav ul li a {
.login-page {
display: grid;
place-items: center;
margin-bottom: 40px;
}
.login-page > div {
width: 40vw;
@ -81,6 +96,10 @@ nav ul li a {
/* forms */
a {
cursor: pointer;
}
form {
padding: 30px;
border-radius: 10px;
@ -109,11 +128,23 @@ form.submitted input:valid {
box-shadow: 0 2px 5px 1px rgba(var(--green), 0.2);
}
form .spacer {
padding-bottom: 10px;
}
form fieldset {
padding-bottom: 15px;
border: none;
}
form fieldset .form-msg {
font-size: 0.9rem;
}
form fieldset .error {
color: rgb(var(--red))
}
form button {
border-radius: 9px 10px;
text-align: center;