28 Commits

Author SHA1 Message Date
9f84234106 fix(deps): remove daysiui
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m59s
2025-02-03 21:44:02 +01:00
a2445a5dd3 chore(deps): update dependency tailwindcss to v4
Some checks failed
Build Docker Image / Build-Docker-Image (push) Failing after 4m16s
2025-02-02 19:32:37 +00:00
a88ed4bb47 fix(deps): update module github.com/golang-migrate/migrate/v4 to v4.18.2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m37s
2025-02-02 10:14:35 +00:00
7ac910aec6 fix(deps): update module github.com/a-h/templ to v0.3.833
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m53s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m3s
2025-02-02 00:07:44 +00:00
15ccd4ef01 chore(deps): update node.js to v22.13.1
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m2s
2025-01-26 01:10:32 +01:00
54f8082430 chore(deps): update golang:1.23.5 docker digest to 8c10f21
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 1m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-01-26 00:05:02 +00:00
0d5143b91b chore(deps): update golang docker tag to v1.23.5
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2025-01-20 04:52:37 +01:00
3d094154ce chore(deps): update debian docker tag to v12.9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2025-01-19 21:03:17 +00:00
3d1111256c chore(deps): update golang:1.23.4 docker digest to 9820aca
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m2s
2025-01-19 20:38:44 +01:00
bc82ad123b chore(deps): update dependency go to v1.23.5
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2025-01-19 01:11:32 +01:00
cb01d5e0d4 chore(deps): update node.js to fa54405
Some checks are pending
Build Docker Image / Build-Docker-Image (push) Successful in 49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Waiting to run
2025-01-19 00:05:01 +00:00
7cb46aad36 chore(deps): update node.js to 816f04d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m50s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2025-01-14 20:21:01 +00:00
92bb836e87 chore(deps): update node.js to v22.13.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2025-01-08 23:05:17 +00:00
1d89f45ff9 fix(deps): update module golang.org/x/net to v0.34.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2025-01-07 23:06:18 +00:00
bc70babaca fix(deps): update module golang.org/x/crypto to v0.32.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m20s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2025-01-06 23:04:59 +00:00
d3700d5a3b fix(deps): update module github.com/a-h/templ to v0.3.819
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 55s
2025-01-02 23:05:26 +00:00
9a8dfc96db chore: #174 update readme
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 57s
2024-12-31 13:23:01 +01:00
52f6d3d706 chore: #174 make into template
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
2024-12-31 12:25:30 +01:00
508aa3038b feat(observability): #360 remove umami to reduce complexity
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2024-12-31 12:03:59 +01:00
0b155af4c9 chore(deps): update dependency daisyui to v4.12.23
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2024-12-27 00:08:13 +01:00
917218da82 chore(deps): update golang:1.23.4 docker digest to 7ea4c9d
Some checks are pending
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Waiting to run
2024-12-26 23:02:20 +00:00
fe7f01e035 chore(deps): update node.js to 0e910f4
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-25 23:02:10 +00:00
55408da398 chore(auth): #331 add and fix forgot password actual tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-25 23:13:58 +01:00
b0f183aeed chore(auth): #331 add and fix forgot password tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m9s
2024-12-25 22:58:37 +01:00
42a910df4b chore(deps): update node.js to 7bea049
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-25 22:26:02 +01:00
73333256c5 chore(deps): update golang:1.23.4 docker digest to b01f7c7
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-25 21:21:30 +00:00
14b477f560 chore(auth): #331 add change password tests
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-25 22:20:52 +01:00
87188724ac chore(deps): update debian:12.8 docker digest to b877a1a
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2024-12-25 20:58:34 +00:00
33 changed files with 1490 additions and 1423 deletions

View File

@@ -11,5 +11,5 @@ jobs:
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: docker build . -t me-fit-test - run: docker build . -t web-app-template-test
- run: docker rmi me-fit-test - run: docker rmi web-app-template-test

View File

@@ -11,8 +11,8 @@ jobs:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }} - run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
- run: docker build . -t git.wundenbergs.de/x/me-fit:latest -t git.wundenbergs.de/x/me-fit:$GITHUB_SHA - run: docker build . -t git.wundenbergs.de/x/web-app-template:latest -t git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker push git.wundenbergs.de/x/me-fit:latest - run: docker push git.wundenbergs.de/x/web-app-template:latest
- run: docker push git.wundenbergs.de/x/me-fit:$GITHUB_SHA - run: docker push git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker rmi git.wundenbergs.de/x/me-fit:latest git.wundenbergs.de/x/me-fit:$GITHUB_SHA - run: docker rmi git.wundenbergs.de/x/web-app-template:latest git.wundenbergs.de/x/web-app-template:$GITHUB_SHA

View File

@@ -3,11 +3,11 @@ dir: mocks/
outpkg: mocks outpkg: mocks
issue-845-fix: True issue-845-fix: True
packages: packages:
me-fit/service: web-app-template/service:
interfaces: interfaces:
Random: Random:
Clock: Clock:
Mail: Mail:
me-fit/db: web-app-template/db:
interfaces: interfaces:
Auth: Auth:

View File

@@ -1,5 +1,5 @@
FROM golang:1.23.4@sha256:70031844b8c225351d0bb63e2c383f80db85d92ba894e3da7e13bcf80efa9a37 AS builder_go FROM golang:1.23.5@sha256:8c10f21bec412f08f73aa7b97ca5ac5f28a39d8a88030ad8a339fd0a781d72b4 AS builder_go
WORKDIR /me-fit WORKDIR /web-app-template
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.62.2 RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.62.2
RUN go install github.com/a-h/templ/cmd/templ@latest RUN go install github.com/a-h/templ/cmd/templ@latest
RUN go install github.com/vektra/mockery/v2@latest RUN go install github.com/vektra/mockery/v2@latest
@@ -10,23 +10,23 @@ RUN templ generate
RUN mockery --log-level warn RUN mockery --log-level warn
RUN go test ./... RUN go test ./...
RUN golangci-lint run ./... RUN golangci-lint run ./...
RUN go build -o /me-fit/me-fit . RUN go build -o /web-app-template/web-app-template .
FROM node:22.12.0@sha256:35a5dd72bcac4bce43266408b58a02be6ff0b6098ffa6f5435aeea980a8951d7 AS builder_node FROM node:22.13.1@sha256:ae2f3d4cc65d251352eca01ba668824f651a2ee4d2a37e2efb22649521a483fd AS builder_node
WORKDIR /me-fit WORKDIR /web-app-template
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm clean-install RUN npm clean-install
COPY . ./ COPY . ./
RUN npm run build RUN npm run build
FROM debian:12.8@sha256:17122fe3d66916e55c0cbd5bbf54bb3f87b3582f4d86a755a0fd3498d360f91b FROM debian:12.9@sha256:321341744acb788e251ebd374aecc1a42d60ce65da7bd4ee9207ff6be6686a62
WORKDIR /me-fit WORKDIR /web-app-template
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
COPY migration ./migration COPY migration ./migration
COPY --from=builder_go /me-fit/me-fit ./me-fit COPY --from=builder_go /web-app-template/web-app-template ./web-app-template
COPY --from=builder_node /me-fit/static ./static COPY --from=builder_node /web-app-template/static ./static
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/me-fit/me-fit"] ENTRYPOINT ["/web-app-template/web-app-template"]

View File

@@ -1,44 +1,98 @@
# stackFAST # Web-App-Template
Your (almost) independent tech stack to host on a VPC. A basic template with authentication to easily host on a VPC.
## Features ## Features
stackFAST includes everything you need to build your App. Focus yourself on developing your idea, instead of "wasting" time on things like setting up auth and observability. This blueprint tries to include as much as possible, but still keep it simple. This template includes everything essential to build an app. It includes the following features:
The blueprint contains the following features:
- Authentication: Users can login, logout, register and reset their password. For increased security TOTP is available aswell. - Authentication: Users can login, logout, register and reset their password. For increased security TOTP is available aswell.
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone. For web analytics umami is included, which is an lighweight self hosted alternative to google analytics. - Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone.
- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included. - Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included.
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains. Best thing is, you can add your more with 3 lines of code - SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains. Best thing is, you can add your more with 3 lines of code
- Actual Stack: SSG SvelteKit + Tailwindcss + DaisyUI + GO Backend for easy and fast feature development - Actual Stack: Tailwindcss + HTMX + DaisyUI + GO Backend with templ and sqlite
## Architecture Design Decisions ## Architecture Design Decisions
### Authentication ### Authentication
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, experts at OWASP don't recommend this in their cheat sheet on that topic. I'm going to explain my criterions and afterwards take a decision. Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision.
There are a few restrictions I would like to contain: There are a few restrictions I would like to contain:
- I want this blueprint do as much as as possible without relying on external services. This way the things needs to be done on other website are very minimal. Furthermore I would like to take back privacy from BigTech. - I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
- I think most cloud services are overpriced. I want to provide an alternative approach with self holsting. But I don't like the idea to spin up 30 services for a small app with 0 users. It should still be possible to run on a small VPC (2vcpu, 2GB). - It should still be possible to run on a small VPC (2vcpu, 2GB).
- It should be as secure as possible - It should be as secure as possible
As of 2024 there are 4 options: I determined 4 options:
- Implement the authentication myself: If I'm holding thight to the cheat sheet, I "should" be able to doge "most" security risks and attacks according to this topic. Unfortanatly I'm not an expert in this field and will do some errors. If people will buy this blueprint, I probably can't sleep well. Especially if real users start using it. At least this has the advantage of not adding adittional services or configuration to the project. 1. Implement the authentication myself
- Using OAuth2 with Google and Apple: Using OAuth2 is the standard for secure applications. Google and Apple has their experts. They deal with attacks every hour of the day. This has the advantage, that users don't have to create new credentials. The only disatvantage is my personal hate on big tech. 2. Using OAuth2 with Keycloak
- Using OAuth2 with Keycloak: Same as above, just that the OAuth2 endpoint is another self hosted service. The only advantage is, it's not proprietary and self hosted. But users are not used to get redirected to a key cloak on sign up. They are used to sign in with Google though. Furthermore Google et. al are protecting themselves against credential stuffing attacks etc. 3. Using OAuth2 with Google and Apple
- Firebase, Clerk, etc.: Users have to sign up again AND blueprint users have to setup another project. 4. Firebase, Clerk, etc.
Even though I would really implement authentication myself, I think OAuth2 with external providers is the best bet. Especially because my reasoning is privacy, which most people just don't care about enough. Using this approach, adding in a keycloak is possible without breaking changes at a later point, as long as I keep the Google Sign In.
#### 1. Implement the authentication myself
It's always possible to implement it myself. The topic of authentication is something special though.
Pros:
- Great Cheat cheets from OWASP
- No adittional configuration or services needed
- Great learning experience on the topic "security"
Cons:
- Great attack vector
- Introcution of vlunerabillities is possible
- No DDOS protection
#### 2. Using OAuth2 with Google and Apple
Instead of implementing authentication from scratch, an external OAuth2 provider is embedded into the application.
Pros:
- The Systems of BigTech are probably safer. They have security experts employed.
- The other external system needs to prevent credential stuffing attacks, etc.
- Users don't have to create new credentials
Cons:
- High dependency on those providers
- Single Point of failure (If your account is banned, your application access get's lost as well)
- It's possible that these providers ban the whole application
- There still needs to be implemented some logic server side
- Full application integration can be difficult
#### 3. Using OAuth2 with Keycloak
This option is almost identical with the previois one, but the provider is self hosted.
Pros:
- Indipendent from 3rd party providers
- The credentials are stored safly
Cons:
- Self hosted (no DDOS protection, etc.)
- There still needs to be implemented some logic server side
- Full application integration can be difficult
#### 4. Firebase, Clerk, etc.
Users can sign in with a seperate sdk on your website
Pros:
- Safe and Sound authentication
Cons:
- Dependent on those providers / adittional setup needed
- Application can be banned
- Still some integration code needed
#### Decision
I've decided on implementing authentication myself, as this is a great learning opportunity. It may not be as secure as other solutions, but if I keep tighly to the OWASP recommendations, it should should good enough.
### Email ### Email
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming mails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails. Thus, the first external service is needed. For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails.
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. You are free to choose a transactional mail provider. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails. Most provider provide 100 mails / day for free. In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails.

View File

@@ -1,8 +1,8 @@
package db package db
import ( import (
"me-fit/log" "web-app-template/log"
"me-fit/types" "web-app-template/types"
"database/sql" "database/sql"
"errors" "errors"

View File

@@ -2,7 +2,7 @@ package db
import ( import (
"database/sql" "database/sql"
"me-fit/types" "web-app-template/types"
"testing" "testing"
"time" "time"

View File

@@ -1,8 +1,8 @@
package db package db
import ( import (
"me-fit/log" "web-app-template/log"
"me-fit/types" "web-app-template/types"
"database/sql" "database/sql"
"errors" "errors"

View File

@@ -1,8 +1,8 @@
package db package db
import ( import (
"me-fit/log" "web-app-template/log"
"me-fit/types" "web-app-template/types"
"database/sql" "database/sql"
"errors" "errors"

16
go.mod
View File

@@ -1,17 +1,19 @@
module me-fit module web-app-template
go 1.22.5 go 1.23
toolchain go1.23.5
require ( require (
github.com/a-h/templ v0.2.793 github.com/a-h/templ v0.3.833
github.com/golang-migrate/migrate/v4 v4.18.1 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/prometheus/client_golang v1.20.5 github.com/prometheus/client_golang v1.20.5
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.32.0
golang.org/x/net v0.33.0 golang.org/x/net v0.34.0
) )
require ( require (
@@ -28,7 +30,7 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.29.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

20
go.sum
View File

@@ -1,13 +1,13 @@
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -51,12 +51,12 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,12 +1,12 @@
package handler package handler
import ( import (
"me-fit/handler/middleware" "web-app-template/handler/middleware"
"me-fit/log" "web-app-template/log"
"me-fit/service" "web-app-template/service"
"me-fit/template/auth" "web-app-template/template/auth"
"me-fit/types" "web-app-template/types"
"me-fit/utils" "web-app-template/utils"
"errors" "errors"
"net/http" "net/http"
@@ -45,12 +45,12 @@ func (handler AuthImpl) Handle(router *http.ServeMux) {
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage()) router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp()) router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
router.Handle("/auth/change-password", handler.handleChangePasswordPage()) router.Handle("GET /auth/change-password", handler.handleChangePasswordPage())
router.Handle("/api/auth/change-password", handler.handleChangePasswordComp()) router.Handle("POST /api/auth/change-password", handler.handleChangePasswordComp())
router.Handle("/auth/forgot-password", handler.handleForgotPasswordPage()) router.Handle("GET /auth/forgot-password", handler.handleForgotPasswordPage())
router.Handle("/api/auth/forgot-password", handler.handleForgotPasswordComp()) router.Handle("POST /api/auth/forgot-password", handler.handleForgotPasswordComp())
router.Handle("/api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp()) router.Handle("POST /api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp())
} }
var ( var (
@@ -307,7 +307,7 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
session := middleware.GetSession(r) session := middleware.GetSession(r)
user := middleware.GetUser(r) user := middleware.GetUser(r)
if session == nil || user == nil { if session == nil || user == nil {
utils.DoRedirect(w, r, "/auth/signin") utils.TriggerToast(w, r, "error", "Unathorized", http.StatusUnauthorized)
return return
} }
@@ -316,7 +316,7 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
err := handler.service.ChangePassword(user, session.Id, currPass, newPass) err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
if err != nil { if err != nil {
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusUnauthorized) utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
return return
} }
@@ -355,14 +355,13 @@ func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
if err != nil { if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError) utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else { } else {
utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent", http.StatusOK) utils.TriggerToast(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
} }
} }
} }
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc { func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL")) pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
if err != nil { if err != nil {
log.Error("Could not get current URL: %v", err) log.Error("Could not get current URL: %v", err)
@@ -375,7 +374,7 @@ func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
err = handler.service.ForgotPassword(token, newPass) err = handler.service.ForgotPassword(token, newPass)
if err != nil { if err != nil {
utils.TriggerToast(w, r, "error", err.Error(), http.StatusInternalServerError) utils.TriggerToast(w, r, "error", err.Error(), http.StatusBadRequest)
} else { } else {
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK) utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
} }

View File

@@ -1,9 +1,9 @@
package handler package handler
import ( import (
"me-fit/handler/middleware" "web-app-template/handler/middleware"
"me-fit/service" "web-app-template/service"
"me-fit/template" "web-app-template/template"
"net/http" "net/http"
@@ -36,13 +36,15 @@ func (handler IndexImpl) handleIndexAnd404() http.HandlerFunc {
var comp templ.Component var comp templ.Component
var status int
if r.URL.Path != "/" { if r.URL.Path != "/" {
comp = template.NotFound() comp = template.NotFound()
w.WriteHeader(http.StatusNotFound) status = http.StatusNotFound
} else { } else {
comp = template.Index() comp = template.Index()
status = http.StatusOK
} }
handler.render.RenderLayout(r, w, comp, user) handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
} }
} }

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"net/http" "net/http"
"me-fit/service" "web-app-template/service"
"me-fit/types" "web-app-template/types"
) )
type ContextKey string type ContextKey string

View File

@@ -5,10 +5,10 @@ import (
"net/http" "net/http"
"strings" "strings"
"me-fit/log" "web-app-template/log"
"me-fit/service" "web-app-template/service"
"me-fit/types" "web-app-template/types"
"me-fit/utils" "web-app-template/utils"
) )
type csrfResponseWriter struct { type csrfResponseWriter struct {

View File

@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"time" "time"
"me-fit/log" "web-app-template/log"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"

View File

@@ -3,7 +3,7 @@ package middleware
import ( import (
"net/http" "net/http"
"me-fit/types" "web-app-template/types"
) )
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler { func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
@@ -15,14 +15,14 @@ func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Han
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
w.Header().Set("Content-Security-Policy", w.Header().Set("Content-Security-Policy",
"default-src 'none'; "+ "default-src 'none'; "+
"script-src 'self' https://umami.me-fit.eu; "+ "script-src 'self'; "+
"connect-src 'self' https://umami.me-fit.eu; "+ "connect-src 'self'; "+
"img-src 'self'; "+ "img-src 'self'; "+
"style-src 'self'; "+ "style-src 'self'; "+
"form-action 'self'; "+ "form-action 'self'; "+
"frame-ancestors 'none'; ", "frame-ancestors 'none'; ",
) )
w.Header().Set("Cross-Origin-Resource-Policy", "same-site") // same-site, as same origin prohibits umami w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp") w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()") w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")

View File

@@ -1,10 +1,10 @@
package handler package handler
import ( import (
"me-fit/log" "web-app-template/log"
"me-fit/template" "web-app-template/template"
"me-fit/template/auth" "web-app-template/template/auth"
"me-fit/types" "web-app-template/types"
"net/http" "net/http"
@@ -12,13 +12,10 @@ import (
) )
type Render struct { type Render struct {
settings *types.Settings
} }
func NewRender(settings *types.Settings) *Render { func NewRender() *Render {
return &Render{ return &Render{}
settings: settings,
}
} }
func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, comp templ.Component, status int) { func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, comp templ.Component, status int) {
@@ -41,7 +38,7 @@ func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) { func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
userComp := render.getUserComp(user) userComp := render.getUserComp(user)
layout := template.Layout(slot, userComp, render.settings.Environment) layout := template.Layout(slot, userComp)
render.RenderWithStatus(r, w, layout, status) render.RenderWithStatus(r, w, layout, status)
} }

View File

@@ -1,10 +1,10 @@
package handler package handler
import ( import (
"me-fit/handler/middleware" "web-app-template/handler/middleware"
"me-fit/service" "web-app-template/service"
"me-fit/template/workout" "web-app-template/template/workout"
"me-fit/utils" "web-app-template/utils"
"net/http" "net/http"
"strconv" "strconv"

14
main.go
View File

@@ -1,12 +1,12 @@
package main package main
import ( import (
"me-fit/db" "web-app-template/db"
"me-fit/handler" "web-app-template/handler"
"me-fit/handler/middleware" "web-app-template/handler/middleware"
"me-fit/log" "web-app-template/log"
"me-fit/service" "web-app-template/service"
"me-fit/types" "web-app-template/types"
"context" "context"
"database/sql" "database/sql"
@@ -113,7 +113,7 @@ func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings) authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings) workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
render := handler.NewRender(serverSettings) render := handler.NewRender()
indexHandler := handler.NewIndex(authService, render) indexHandler := handler.NewIndex(authService, render)
authHandler := handler.NewAuth(authService, render) authHandler := handler.NewAuth(authService, render)
workoutHandler := handler.NewWorkout(workoutService, authService, render) workoutHandler := handler.NewWorkout(workoutService, authService, render)

View File

@@ -11,8 +11,8 @@ import (
"testing" "testing"
"time" "time"
"me-fit/service" "web-app-template/service"
"me-fit/types" "web-app-template/types"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -82,15 +82,15 @@ func TestIntegrationSecurityHeader(t *testing.T) {
value = resp.Header.Get("Content-Security-Policy") value = resp.Header.Get("Content-Security-Policy")
assert.Equal(t, "default-src 'none'; "+ assert.Equal(t, "default-src 'none'; "+
"script-src 'self' https://umami.me-fit.eu; "+ "script-src 'self'; "+
"connect-src 'self' https://umami.me-fit.eu; "+ "connect-src 'self'; "+
"img-src 'self'; "+ "img-src 'self'; "+
"style-src 'self'; "+ "style-src 'self'; "+
"form-action 'self'; "+ "form-action 'self'; "+
"frame-ancestors 'none';", value) "frame-ancestors 'none';", value)
value = resp.Header.Get("Cross-Origin-Resource-Policy") value = resp.Header.Get("Cross-Origin-Resource-Policy")
assert.Equal(t, "same-site", value) assert.Equal(t, "same-origin", value)
value = resp.Header.Get("Cross-Origin-Opener-Policy") value = resp.Header.Get("Cross-Origin-Opener-Policy")
assert.Equal(t, "same-origin", value) assert.Equal(t, "same-origin", value)
@@ -933,7 +933,187 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, 0, rows) assert.Equal(t, 0, rows)
}) })
}) })
t.Run("ChangePassword", func(t *testing.T) { t.Run("ChangePassword", func(t *testing.T) {
t.Run(`should redirect to "/" if not signed in`, func(t *testing.T) {
t.Parallel()
_, basePath, ctx := setupIntegrationTest(t)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/auth/signin", resp.Header.Get("Location"))
})
t.Run(`should throw unautohorized if not signed in`, func(t *testing.T) {
t.Parallel()
_, basePath, ctx := setupIntegrationTest(t)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/signin", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
anonymousCsrfToken := findCsrfToken(html)
assert.NotEqual(t, "", anonymousCsrfToken)
anonymousSessionId := findCookie(resp, "id").Value
assert.NotEqual(t, "", anonymousSessionId)
formData := url.Values{
"current-password": {"password"},
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {anonymousCsrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+anonymousSessionId)
req.Header.Set("HX-Request", "true")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run(`should fail if csrf token is invalid`, func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
assert.Nil(t, err)
formData := url.Values{
"current-password": {"password"},
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {"invalid-csrf-token"},
}
req, err := http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+sessionId)
req.Header.Set("HX-Request", "true")
resp, err := httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
t.Run("should fail if current password does not match", func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
assert.Nil(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
csrfToken := findCsrfToken(html)
assert.NotEqual(t, "", csrfToken)
formData := url.Values{
"current-password": {"wrong-password"},
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {csrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+sessionId)
req.Header.Set("HX-Request", "true")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
t.Run("should fail if new password is insecure", func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
assert.Nil(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
csrfToken := findCsrfToken(html)
assert.NotEqual(t, "", csrfToken)
formData := url.Values{
"current-password": {"password"},
"new-password": {"insecure-password"},
"csrf-token": {csrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+sessionId)
req.Header.Set("HX-Request", "true")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
t.Run("should change password and invalidate all other user sessions", func(t *testing.T) { t.Run("should change password and invalidate all other user sessions", func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -989,6 +1169,12 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
pass = service.GetHashPassword("MyNewSecurePassword1!", []byte("salt"))
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
var sessionIds []string var sessionIds []string
sessions, err := db.Query(`SELECT session_id FROM session WHERE NOT user_id = ? ORDER BY session_id`, uuid.Nil) sessions, err := db.Query(`SELECT session_id FROM session WHERE NOT user_id = ? ORDER BY session_id`, uuid.Nil)
assert.Nil(t, err) assert.Nil(t, err)
@@ -1005,7 +1191,288 @@ func TestIntegrationAuth(t *testing.T) {
}) })
}) })
t.Run("ForgotPassword", func(t *testing.T) { t.Run("ForgotPasswordMail", func(t *testing.T) {
t.Run(`should redirect to "/" if signed in`, func(t *testing.T) {
t.Parallel()
d, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
assert.Nil(t, err)
sessionId := "session-id"
_, err = d.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("session-id", ?, datetime(), datetime("now", "+1 day"))`, userId)
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/", resp.Header.Get("Location"))
})
t.Run(`should fail if csrf token is invalid`, func(t *testing.T) {
t.Parallel()
d, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
anonymousSessionId := findCookie(resp, "id").Value
assert.NotEqual(t, "", anonymousSessionId)
formData := url.Values{
"email": {"mail@mail.de"},
"csrf-token": {"invalid-csrf-token"},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 0, rows)
})
t.Run(`should fail but respond with uniform message`, func(t *testing.T) {
t.Parallel()
_, basePath, ctx := setupIntegrationTest(t)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
anonymousSessionId := findCookie(resp, "id").Value
assert.NotEqual(t, "", anonymousSessionId)
body, err := html.Parse(resp.Body)
assert.Nil(t, err)
anonymousCsrfToken := findCsrfToken(body)
formData := url.Values{
"email": {"non-existent@mail.de"},
"csrf-token": {anonymousCsrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
req.Header.Set("Cookie", "id="+anonymousSessionId)
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
msg := "If the address exists, an email has been sent."
assert.Contains(t, resp.Header.Get("HX-Trigger"), msg)
})
t.Run(`should generate token and respond with uniform message`, func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
anonymousSessionId := findCookie(resp, "id").Value
assert.NotEqual(t, "", anonymousSessionId)
body, err := html.Parse(resp.Body)
assert.Nil(t, err)
anonymousCsrfToken := findCsrfToken(body)
formData := url.Values{
"email": {"mail@mail.de"},
"csrf-token": {anonymousCsrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
req.Header.Set("Cookie", "id="+anonymousSessionId)
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
msg := "If the address exists, an email has been sent."
assert.Contains(t, resp.Header.Get("HX-Trigger"), msg)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
})
t.Run("ForgotPasswordResponse", func(t *testing.T) {
t.Run(`should fail if token does not exist`, func(t *testing.T) {
t.Parallel()
d, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
anonymousSessionId := findCookie(resp, "id").Value
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
anonymousCsrfToken := findCsrfToken(html)
assert.NotEqual(t, "", anonymousCsrfToken)
formData := url.Values{
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {anonymousCsrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password-actual", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+anonymousSessionId)
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Current-URL", basePath+"/auth/change-password?token=invalidToken")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
t.Run(`should fail if token is outdated`, func(t *testing.T) {
t.Parallel()
d, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
anonymousSessionId := findCookie(resp, "id").Value
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
anonymousCsrfToken := findCsrfToken(html)
assert.NotEqual(t, "", anonymousCsrfToken)
token := "password-reset-token"
_, err = d.Exec(`
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, ?, ?, datetime("now", "-16 minute"), datetime("now", "-1 minute"))`, token, userId, "", types.TokenTypePasswordReset)
assert.Nil(t, err)
formData := url.Values{
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {anonymousCsrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password-actual", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+anonymousSessionId)
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Current-URL", basePath+"/auth/change-password?token="+url.QueryEscape(token))
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
t.Run(`should fail if password is insecure`, func(t *testing.T) {
t.Parallel()
d, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
anonymousSessionId := findCookie(resp, "id").Value
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
anonymousCsrfToken := findCsrfToken(html)
assert.NotEqual(t, "", anonymousCsrfToken)
token := "password-reset-token"
_, err = d.Exec(`
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, ?, ?, datetime("now"), datetime("now", "+15 minute"))`, token, userId, "", types.TokenTypePasswordReset)
assert.Nil(t, err)
formData := url.Values{
"new-password": {"insecure-password"},
"csrf-token": {anonymousCsrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password-actual", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+anonymousSessionId)
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Current-URL", basePath+"/auth/change-password?token="+url.QueryEscape(token))
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 1, rows)
})
t.Run("should change password and invalidate ALL sessions", func(t *testing.T) { t.Run("should change password and invalidate ALL sessions", func(t *testing.T) {
t.Parallel() t.Parallel()

2073
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{ {
"name": "me-fit", "name": "web-app-template",
"version": "1.0.0", "version": "1.0.0",
"description": "Your (almost) independent tech stack to host on a VPC.", "description": "Your (almost) independent tech stack to host on a VPC.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss build -o static/css/tailwind.css --minify", "build": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -o static/css/tailwind.css --minify",
"watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss build -o static/css/tailwind.css --watch", "watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -o static/css/tailwind.css --watch",
"test": "" "test": ""
}, },
"keywords": [], "keywords": [],
@@ -13,7 +13,7 @@
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"htmx.org": "2.0.4", "htmx.org": "2.0.4",
"tailwindcss": "3.4.17", "tailwindcss": "4.0.3",
"daisyui": "4.12.22" "@tailwindcss/cli": "4.0.3"
} }
} }

View File

@@ -8,10 +8,10 @@ import (
"strings" "strings"
"time" "time"
"me-fit/db" "web-app-template/db"
"me-fit/log" "web-app-template/log"
mailTemplate "me-fit/template/mail" mailTemplate "web-app-template/template/mail"
"me-fit/types" "web-app-template/types"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
@@ -249,7 +249,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
return return
} }
service.mail.SendMail(email, "Welcome to ME-FIT", w.String()) service.mail.SendMail(email, "Welcome to web-app-template", w.String())
} }
func (service AuthImpl) VerifyUserEmail(tokenStr string) error { func (service AuthImpl) VerifyUserEmail(tokenStr string) error {

View File

@@ -1,9 +1,9 @@
package service package service
import ( import (
"me-fit/db" "web-app-template/db"
"me-fit/mocks" "web-app-template/mocks"
"me-fit/types" "web-app-template/types"
"strings" "strings"
"testing" "testing"
@@ -127,7 +127,7 @@ func TestSendVerificationMail(t *testing.T) {
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil) mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
mockMail.EXPECT().SendMail(email, "Welcome to ME-FIT", mock.MatchedBy(func(message string) bool { mockMail.EXPECT().SendMail(email, "Welcome to web-app-template", mock.MatchedBy(func(message string) bool {
return strings.Contains(message, token.Token) return strings.Contains(message, token.Token)
})).Return() })).Return()

View File

@@ -1,8 +1,8 @@
package service package service
import ( import (
"me-fit/log" "web-app-template/log"
"me-fit/types" "web-app-template/types"
"fmt" "fmt"
"net/smtp" "net/smtp"

View File

@@ -1,8 +1,8 @@
package service package service
import ( import (
"me-fit/log" "web-app-template/log"
"me-fit/types" "web-app-template/types"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"

View File

@@ -1,8 +1,8 @@
package service package service
import ( import (
"me-fit/db" "web-app-template/db"
"me-fit/types" "web-app-template/types"
"errors" "errors"
"strconv" "strconv"

View File

@@ -15,12 +15,6 @@ module.exports = {
}, },
}), }),
}, },
}, }
plugins: [
require('daisyui'),
],
daisyui: {
themes: ["retro"],
},
} }

View File

@@ -1,16 +1,16 @@
package template package template
templ Index() { templ Index() {
<div class="hero bg-base-200 h-full"> <div class="hero bg-base-200 h-full">
<div class="hero-content text-center"> <div class="hero-content text-center">
<div class="max-w-md"> <div class="max-w-md">
<h1 class="text-5xl font-bold">Next Level Workout Tracker</h1> <h1 class="text-5xl font-bold">Next Level Workout Tracker</h1>
<p class="py-6"> <p class="py-6">
Ever wanted to track your workouts and see your progress over time? ME-FIT is the perfect Ever wanted to track your workouts and see your progress over time? web-app-template is the perfect
solution for you. solution for you.
</p> </p>
<a href="/workout" class="btn btn-primary">Get Started</a> <a href="/workout" class="btn btn-primary">Get Started</a>
</div>
</div> </div>
</div> </div>
</div>
} }

View File

@@ -1,18 +1,15 @@
package template package template
templ Layout(slot templ.Component, user templ.Component, environment string) { templ Layout(slot templ.Component, user templ.Component) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>ME-FIT</title> <title>web-app-template</title>
<link rel="icon" href="/static/favicon.svg" /> <link rel="icon" href="/static/favicon.svg" />
<link rel="stylesheet" href="/static/css/tailwind.css" /> <link rel="stylesheet" href="/static/css/tailwind.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
if environment == "prod" {
<script defer src="https://umami.me-fit.eu/script.js" data-website-id="3c8efb09-44e4-4372-8a1e-c3bc675cd89a"></script>
}
<meta name="htmx-config" content='{ <meta name="htmx-config" content='{
"includeIndicatorStyles": false, "includeIndicatorStyles": false,
"selfRequestsOnly": true, "selfRequestsOnly": true,
@@ -26,8 +23,8 @@ templ Layout(slot templ.Component, user templ.Component, environment string) {
<div class="h-screen flex flex-col"> <div class="h-screen flex flex-col">
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow"> <div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow">
<a href="/" class="flex-1 flex gap-2"> <a href="/" class="flex-1 flex gap-2">
<img src="/static/favicon.svg" alt="ME-FIT logo" /> <img src="/static/favicon.svg" alt="web-app-template logo" />
<span>ME-FIT</span> <span>web-app-template</span>
</a> </a>
@user @user
</div> </div>

View File

@@ -1,7 +1,7 @@
package types package types
import ( import (
"me-fit/log" "web-app-template/log"
) )
type Settings struct { type Settings struct {

View File

@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"me-fit/log" "web-app-template/log"
) )
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) { func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {