15 Commits

Author SHA1 Message Date
max
6d6aff50b3 Only show logout (now CSRF protected) if user is authenticated, include relevant authentication logic in GET controllers (this should be moved to middleware) 2024-02-14 13:20:35 -06:00
max
a6be73765a Add GET verb to static handler 2024-02-14 13:16:52 -06:00
max
ddc9e51831 Fix boolean column migration example 2024-02-14 13:16:15 -06:00
max
dc450e26dd Move logout to POST route and controller with CSRF middleware. Add CsrfToken to home for logout 2024-02-12 14:46:26 -06:00
max
de4a217c5f Update extended crypto library 2024-02-09 14:47:29 -06:00
max
c4e83d06b9 Bump go version to 1.22 2024-02-09 14:21:45 -06:00
max
51da24be9b Small formatting fix 2024-02-09 13:44:21 -06:00
Maximilian
e497f4d2f0 Ignore fields that are zero value 2024-01-20 16:32:07 -06:00
Maximilian
b30af86e58 Prebuild templates (base.html + content) at startup to avoid a file parse every page load 2023-12-22 21:03:15 -06:00
Maximilian
3ffd548623 Fix ordering for html attributes 2023-12-21 00:14:28 -06:00
Maximilian
cb4f10e0b4 Better alignment for memory 2023-12-19 16:41:31 -06:00
Maximilian
878ce01b29 Get the sha256 hash of password before passing to bcrypt to avoid character limit 2023-12-19 16:06:00 -06:00
Maximilian
c82cdb4f13 Use best naming practices 2023-12-18 23:04:31 -06:00
Maximilian
ce81c36e9f Update x/crypto 2023-12-18 23:01:19 -06:00
Maximilian
ab1b82c680 Update x/crypto 2023-10-10 21:37:54 -05:00
18 changed files with 190 additions and 86 deletions

View File

@@ -17,8 +17,8 @@ type Scheduled struct {
}
type Task struct {
Interval time.Duration
Funcs []func(app *App)
Interval time.Duration
}
func RunScheduledTasks(app *App, poolSize int, stop <-chan struct{}) {
@@ -27,13 +27,13 @@ func RunScheduledTasks(app *App, poolSize int, stop <-chan struct{}) {
}
tasks := []Task{
{Interval: time.Second, Funcs: app.ScheduledTasks.EverySecond},
{Interval: time.Minute, Funcs: app.ScheduledTasks.EveryMinute},
{Interval: time.Hour, Funcs: app.ScheduledTasks.EveryHour},
{Interval: 24 * time.Hour, Funcs: app.ScheduledTasks.EveryDay},
{Interval: 7 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryWeek},
{Interval: 30 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryMonth},
{Interval: 365 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryYear},
{Funcs: app.ScheduledTasks.EverySecond, Interval: time.Second},
{Funcs: app.ScheduledTasks.EveryMinute, Interval: time.Minute},
{Funcs: app.ScheduledTasks.EveryHour, Interval: time.Hour},
{Funcs: app.ScheduledTasks.EveryDay, Interval: 24 * time.Hour},
{Funcs: app.ScheduledTasks.EveryWeek, Interval: 7 * 24 * time.Hour},
{Funcs: app.ScheduledTasks.EveryMonth, Interval: 30 * 24 * time.Hour},
{Funcs: app.ScheduledTasks.EveryYear, Interval: 365 * 24 * time.Hour},
}
var wg sync.WaitGroup

View File

@@ -23,7 +23,8 @@ type Configuration struct {
}
Template struct {
BaseName string `json:"BaseTemplateName"`
BaseName string `json:"BaseTemplateName"`
ContentPath string `json:"ContentPath"`
}
}

View File

@@ -13,21 +13,37 @@ type Get struct {
App *app.App
}
func (g *Get) ShowHome(w http.ResponseWriter, _ *http.Request) {
func (g *Get) ShowHome(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
Test string
CsrfToken string
IsAuthenticated bool
Test string
}
CsrfToken, err := security.GenerateCsrfToken(w, r)
if err != nil {
return
}
isAuthenticated := true
user, err := models.CurrentUser(g.App, r)
if err != nil || user.Id == 0 {
isAuthenticated = false
}
data := dataStruct{
Test: "Hello World!",
CsrfToken: CsrfToken,
Test: "Hello World!",
IsAuthenticated: isAuthenticated,
}
templating.RenderTemplate(g.App, w, "templates/pages/home.html", data)
templating.RenderTemplate(w, "templates/pages/home.html", data)
}
func (g *Get) ShowRegister(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
CsrfToken string
CsrfToken string
IsAuthenticated bool
}
CsrfToken, err := security.GenerateCsrfToken(w, r)
@@ -35,16 +51,24 @@ func (g *Get) ShowRegister(w http.ResponseWriter, r *http.Request) {
return
}
data := dataStruct{
CsrfToken: CsrfToken,
isAuthenticated := true
user, err := models.CurrentUser(g.App, r)
if err != nil || user.Id == 0 {
isAuthenticated = false
}
templating.RenderTemplate(g.App, w, "templates/pages/register.html", data)
data := dataStruct{
CsrfToken: CsrfToken,
IsAuthenticated: isAuthenticated,
}
templating.RenderTemplate(w, "templates/pages/register.html", data)
}
func (g *Get) ShowLogin(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
CsrfToken string
CsrfToken string
IsAuthenticated bool
}
CsrfToken, err := security.GenerateCsrfToken(w, r)
@@ -56,10 +80,5 @@ func (g *Get) ShowLogin(w http.ResponseWriter, r *http.Request) {
CsrfToken: CsrfToken,
}
templating.RenderTemplate(g.App, w, "templates/pages/login.html", data)
}
func (g *Get) Logout(w http.ResponseWriter, r *http.Request) {
models.LogoutUser(g.App, w, r)
http.Redirect(w, r, "/", http.StatusFound)
templating.RenderTemplate(w, "templates/pages/login.html", data)
}

View File

@@ -50,3 +50,8 @@ func (p *Post) Register(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusFound)
}
func (p *Post) Logout(w http.ResponseWriter, r *http.Request) {
models.LogoutUser(p.App, w, r)
http.Redirect(w, r, "/", http.StatusFound)
}

View File

@@ -9,7 +9,8 @@ import (
"reflect"
)
// Migrate given a dummy object of any type, it will create a table with the same name as the type and create columns with the same name as the fields of the object
// Migrate given a dummy object of any type, it will create a table with the same name
// as the type and create columns with the same name as the fields of the object
func Migrate(app *app.App, anyStruct interface{}) error {
valueOfStruct := reflect.ValueOf(anyStruct)
typeOfStruct := valueOfStruct.Type()
@@ -23,10 +24,15 @@ func Migrate(app *app.App, anyStruct interface{}) error {
for i := 0; i < valueOfStruct.NumField(); i++ {
fieldType := typeOfStruct.Field(i)
fieldName := fieldType.Name
if fieldName != "Id" && fieldName != "id" {
err := createColumn(app, tableName, fieldName, fieldType.Type.Name())
if err != nil {
return err
// Create column if dummy for migration is NOT zero value
fieldValue := valueOfStruct.Field(i).Interface()
if !reflect.ValueOf(fieldValue).IsZero() {
if fieldName != "Id" && fieldName != "id" {
err := createColumn(app, tableName, fieldName, fieldType.Type.Name())
if err != nil {
return err
}
}
}
}

View File

@@ -12,6 +12,7 @@
"HttpPort": "8090"
},
"Template": {
"BaseTemplateName": "templates/base.html"
"BaseTemplateName": "templates/base.html",
"ContentPath": "templates"
}
}

4
go.mod
View File

@@ -1,8 +1,8 @@
module GoWeb
go 1.21
go 1.22
require (
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.13.0
golang.org/x/crypto v0.19.0
)

4
go.sum
View File

@@ -1,4 +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=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=

View File

@@ -6,6 +6,7 @@ import (
"GoWeb/database"
"GoWeb/models"
"GoWeb/routes"
"GoWeb/templating"
"context"
"embed"
"errors"
@@ -67,6 +68,13 @@ func main() {
routes.Get(&appLoaded)
routes.Post(&appLoaded)
// Prepare templates
err = templating.BuildPages(&appLoaded)
if err != nil {
slog.Error("error building templates: " + err.Error())
os.Exit(1)
}
// Start server
server := &http.Server{Addr: appLoaded.Config.Listen.Ip + ":" + appLoaded.Config.Listen.Port}
go func() {

View File

@@ -25,7 +25,7 @@ func RunAllMigrations(app *app.App) error {
Id: 1,
UserId: 1,
AuthToken: "migrate",
RememberMe: false,
RememberMe: true, // Booleans must be true to migrate properly
CreatedAt: time.Now(),
}
err = database.Migrate(app, session)

View File

@@ -17,7 +17,7 @@ type Session struct {
CreatedAt time.Time
}
const sessionColumnsNoId = "\"UserId\", \"AuthToken\",\"RememberMe\", \"CreatedAt\""
const sessionColumnsNoId = "\"UserId\", \"AuthToken\", \"RememberMe\", \"CreatedAt\""
const sessionColumns = "\"Id\", " + sessionColumnsNoId
const sessionTable = "public.\"Session\""
@@ -62,7 +62,7 @@ func CreateSession(app *app.App, w http.ResponseWriter, userId int64, remember b
return session, nil
}
func GetSessionByAuthToken(app *app.App, authToken string) (Session, error) {
func SessionByAuthToken(app *app.App, authToken string) (Session, error) {
session := Session{}
err := app.Db.QueryRow(selectSessionByAuthToken, authToken).Scan(&session.Id, &session.UserId, &session.AuthToken, &session.RememberMe, &session.CreatedAt)

View File

@@ -2,6 +2,8 @@ package models
import (
"GoWeb/app"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"time"
@@ -27,23 +29,23 @@ const (
insertUser = "INSERT INTO " + userTable + " (" + userColumnsNoId + ") VALUES ($1, $2, $3, $4) RETURNING \"Id\""
)
// GetCurrentUser finds the currently logged-in user by session cookie
func GetCurrentUser(app *app.App, r *http.Request) (User, error) {
// CurrentUser finds the currently logged-in user by session cookie
func CurrentUser(app *app.App, r *http.Request) (User, error) {
cookie, err := r.Cookie("session")
if err != nil {
return User{}, err
}
session, err := GetSessionByAuthToken(app, cookie.Value)
session, err := SessionByAuthToken(app, cookie.Value)
if err != nil {
return User{}, err
}
return GetUserById(app, session.UserId)
return UserById(app, session.UserId)
}
// GetUserById finds a User table row in the database by id and returns a struct representing this row
func GetUserById(app *app.App, id int64) (User, error) {
// UserById finds a User table row in the database by id and returns a struct representing this row
func UserById(app *app.App, id int64) (User, error) {
user := User{}
err := app.Db.QueryRow(selectUserById, id).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
@@ -54,8 +56,8 @@ func GetUserById(app *app.App, id int64) (User, error) {
return user, nil
}
// GetUserByUsername finds a User table row in the database by username and returns a struct representing this row
func GetUserByUsername(app *app.App, username string) (User, error) {
// UserByUsername finds a User table row in the database by username and returns a struct representing this row
func UserByUsername(app *app.App, username string) (User, error) {
user := User{}
err := app.Db.QueryRow(selectUserByUsername, username).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
@@ -68,7 +70,12 @@ func GetUserByUsername(app *app.App, username string) (User, error) {
// CreateUser creates a User table row in the database
func CreateUser(app *app.App, username string, password string, createdAt time.Time, updatedAt time.Time) (User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// Get sha256 hash of password then get bcrypt hash to store
hash256 := sha256.New()
hash256.Write([]byte(password))
hashSum := hash256.Sum(nil)
hashString := hex.EncodeToString(hashSum)
hash, err := bcrypt.GenerateFromPassword([]byte(hashString), bcrypt.DefaultCost)
if err != nil {
slog.Error("error hashing password: " + err.Error())
return User{}, err
@@ -82,7 +89,7 @@ func CreateUser(app *app.App, username string, password string, createdAt time.T
return User{}, err
}
return GetUserById(app, lastInsertId)
return UserById(app, lastInsertId)
}
// AuthenticateUser validates the password for the specified user
@@ -95,7 +102,12 @@ func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, pass
return Session{}, err
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
// Get sha256 hash of password then check bcrypt
hash256 := sha256.New()
hash256.Write([]byte(password))
hashSum := hash256.Sum(nil)
hashString := hex.EncodeToString(hashSum)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(hashString))
if err != nil { // Failed to validate password, doesn't match
slog.Info("incorrect password:" + username)
return Session{}, err

View File

@@ -22,12 +22,11 @@ func Get(app *app.App) {
return
}
staticHandler := http.FileServer(http.FS(staticFS))
http.Handle("/static/", http.StripPrefix("/static/", staticHandler))
http.Handle("GET /static/", http.StripPrefix("/static/", staticHandler))
slog.Info("serving static files from embedded file system /static")
// Pages
http.HandleFunc("/", getController.ShowHome)
http.HandleFunc("/login", getController.ShowLogin)
http.HandleFunc("/register", getController.ShowRegister)
http.HandleFunc("/logout", getController.Logout)
http.HandleFunc("GET /", getController.ShowHome)
http.HandleFunc("GET /login", getController.ShowLogin)
http.HandleFunc("GET /register", getController.ShowRegister)
}

View File

@@ -15,6 +15,7 @@ func Post(app *app.App) {
}
// User authentication
http.HandleFunc("/register-handle", middleware.Csrf(postController.Register))
http.HandleFunc("/login-handle", middleware.Csrf(postController.Login))
http.HandleFunc("POST /register-handle", middleware.Csrf(postController.Register))
http.HandleFunc("POST /login-handle", middleware.Csrf(postController.Login))
http.HandleFunc("POST /logout", middleware.Csrf(postController.Logout))
}

View File

@@ -3,9 +3,24 @@
<head>
<meta charset="UTF-8">
<title>SiteName - {{ template "pageTitle" }}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link href="/static/css/style.css" rel="stylesheet">
</head>
<body>
<div class="navbar">
{{ if .IsAuthenticated }}
<form action="/logout" method="post">
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<input type="submit" value="Logout">
</form>
{{ else }}
<form action="/login" method="get">
<input type="submit" value="Login">
</form>
<form action="/register" method="get">
<input type="submit" value="Register">
</form>
{{ end }}
</div>
{{ template "content" . }}
<div class="footer-container">
<footer>

View File

@@ -7,11 +7,11 @@
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<label for="username">Username:</label><br>
<input id="username" name="username" type="text" placeholder="John"><br><br>
<input id="username" name="username" placeholder="John" type="text"><br><br>
<label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br>
<label for="remember">Remember Me:</label>
<input id="remember" type="checkbox" name="remember"><br><br>
<input id="remember" name="remember" type="checkbox"><br><br>
<input type="submit" value="Submit">
</form>
</div>

View File

@@ -7,7 +7,7 @@
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<label for="username">Username:</label><br>
<input id="username" name="username" type="text" placeholder="John"><br><br>
<input id="username" name="username" placeholder="John" type="text"><br><br>
<label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br>
<input type="submit" value="Submit">

View File

@@ -2,45 +2,82 @@ package templating
import (
"GoWeb/app"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
)
// RenderTemplate renders and serves a template from the embedded filesystem optionally with given data
func RenderTemplate(app *app.App, w http.ResponseWriter, contentPath string, data any) {
templatePath := app.Config.Template.BaseName
var templates = make(map[string]*template.Template) // This is only used here, does not need to be in app.App
templateContent, err := app.Res.ReadFile(templatePath)
func BuildPages(app *app.App) error {
basePath := app.Config.Template.BaseName
baseContent, err := app.Res.ReadFile(basePath)
if err != nil {
return fmt.Errorf("error reading base file: %w", err)
}
base, err := template.New(basePath).Parse(string(baseContent)) // Sets filepath as name and parses content
if err != nil {
return fmt.Errorf("error parsing base file: %w", err)
}
readFilesRecursively := func(fsys fs.FS, root string) ([]string, error) {
var files []string
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("error walking the path %q: %w", path, err)
}
if !d.IsDir() {
files = append(files, path)
}
return nil
})
return files, err
}
// Get all file paths in the directory tree
filePaths, err := readFilesRecursively(app.Res, app.Config.Template.ContentPath)
if err != nil {
return fmt.Errorf("error reading files recursively: %w", err)
}
for _, contentPath := range filePaths { // Create a new template base + content for each page
content, err := app.Res.ReadFile(contentPath)
if err != nil {
return fmt.Errorf("error reading content file %s: %w", contentPath, err)
}
t, err := base.Clone()
if err != nil {
return fmt.Errorf("error cloning base template: %w", err)
}
_, err = t.Parse(string(content))
if err != nil {
return fmt.Errorf("error parsing content: %w", err)
}
templates[contentPath] = t
}
return nil
}
func RenderTemplate(w http.ResponseWriter, contentPath string, data any) {
t, ok := templates[contentPath]
if !ok {
err := fmt.Errorf("template not found for path: %s", contentPath)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
http.Error(w, "Template not found", 404)
return
}
t, err := template.New(templatePath).Parse(string(templateContent))
if err != nil {
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
content, err := app.Res.ReadFile(contentPath)
if err != nil {
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
t, err = t.Parse(string(content))
if err != nil {
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, data)
err := t.Execute(w, data) // Execute prebuilt template with dynamic data
if err != nil {
err = fmt.Errorf("error executing template: %w", err)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return