all repos — well-binge @ f57a55bbacf624b72827d15dc12081258118746b

Create positive, recurring habits.

Initial commit
Marco Andronaco andronacomarco@gmail.com
Sun, 13 Oct 2024 01:12:45 +0200
commit

f57a55bbacf624b72827d15dc12081258118746b

A .env.example

@@ -0,0 +1,8 @@

+APP_PORT=3000 +APP_BASE_URL=http://localhost:3000 +APP_PEPPER=AnyRandomString +APP_REGISTRATION_ENABLED=true +APP_SMTP_EMAIL=your-address@gmail.com +APP_SMTP_PASSWORD=yourpassword +APP_SMTP_HOST=smtp.gmail.com +APP_SMTP_PORT=587
A .github/workflows/publish.yaml

@@ -0,0 +1,48 @@

+# +name: Create and publish a Docker image + +# Configures this workflow to run every time a change is pushed to the branch called `main`. +on: + push: + branches: ['main'] + +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + # + steps: + - name: Checkout repository + uses: actions/checkout@v3 + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + - name: Build and push Docker image + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }}
A .gitignore

@@ -0,0 +1,5 @@

+.env +data +*.db +__debug_bin* +dist
A .vscode/launch.json

@@ -0,0 +1,15 @@

+{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "." + } + ] +}
A Dockerfile

@@ -0,0 +1,33 @@

+# syntax=docker/dockerfile:1 + +FROM golang:1.23-alpine AS builder + +WORKDIR /build + +# Download Go modules +COPY go.mod go.sum ./ +RUN go mod download +RUN go mod verify + +# Transfer source code +COPY src ./src +COPY *.go ./ + +# Build +RUN CGO_ENABLED=0 go build -trimpath -o /dist/app + + +# Test +FROM builder AS run-test-stage +COPY templates ./templates +RUN go test -v ./... + +FROM scratch AS build-release-stage + +WORKDIR /app + +COPY static ./static +COPY templates ./templates +COPY --from=builder /dist . + +ENTRYPOINT ["./app"]
A LICENSE

@@ -0,0 +1,22 @@

+The MIT License (MIT) + +Copyright (c) BiRabittoh + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
A README.md

@@ -0,0 +1,29 @@

+# auth-boilerplate + +This web-app features: +* a working folder-based file structure, +* a basic authentication service, +* a basic SMTP implementation, +* a Dockerfile for convenience, +* a GitHub workflow to create packages. + + +## Environment + +All environment variables are optional, but some features might be disabled depending on what you have set. + +* `APP_PORT`: defaults to `3000`. +* `APP_BASE_URL`: defaults to `http://localhost:<port>`. +* `APP_PEPPER`: random string, used for password hashing. +* `APP_REGISTRATION_ENABLED`: defaults to `true`. +* `APP_SMTP_EMAIL`: email address you want to send mails from. +* `APP_SMTP_PASSWORD`: password for said email address. +* `APP_SMTP_HOST`: host for the SMTP server. +* `APP_SMTP_PORT`: port for the SMTP server. + +This application also looks for a `.env` file in the current directory. + + +## License + +auth-boilerplate is licensed under MIT.
A docker-compose.yaml

@@ -0,0 +1,16 @@

+name: app + +services: + app: + build: . + image: ghcr.io/birabittoh/auth-boilerplate:main + container_name: app + restart: unless-stopped + env_file: .env + ports: + - 3000:3000 +# volumes: +# - /etc/localtime:/etc/localtime:ro +# - ./templates:/app/templates +# - ./static:/app/static +# - ./data:/app/data
A go.mod

@@ -0,0 +1,28 @@

+module github.com/birabittoh/auth-boilerplate + +go 1.23.2 + +require ( + github.com/birabittoh/myks v0.0.2 + github.com/glebarez/sqlite v1.11.0 + github.com/joho/godotenv v1.5.1 + github.com/utking/extemplate v0.0.0-20240811163052-49c208254ff2 + golang.org/x/crypto v0.28.0 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +)
A go.sum

@@ -0,0 +1,42 @@

+github.com/birabittoh/myks v0.0.2 h1:EBukMUsAflwiqdNo4LE7o2WQdEvawty5ewCZWY+IXSU= +github.com/birabittoh/myks v0.0.2/go.mod h1:klNWaeUWm7TmhnBHBMt9vALwCHW11/Xw1BpCNkCx7hs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/utking/extemplate v0.0.0-20240811163052-49c208254ff2 h1:GvYJOhvifh/8nUBNnb+LPk+U9p9SLWSyGu4GQr9fAi8= +github.com/utking/extemplate v0.0.0-20240811163052-49c208254ff2/go.mod h1:1WxnPx53d4RfgrIlNkhRTp37c/82H/KEUuul+Wh26dM= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
A main.go

@@ -0,0 +1,9 @@

+package main + +import ( + "github.com/birabittoh/auth-boilerplate/src/app" +) + +func main() { + app.Main() +}
A src/app/functions.go

@@ -0,0 +1,130 @@

+package app + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/birabittoh/auth-boilerplate/src/email" +) + +const ( + minUsernameLength = 3 + maxUsernameLength = 10 +) + +var ( + validUsername = regexp.MustCompile(`(?i)^[a-z0-9._-]+$`) + validEmail = regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`) +) + +func getUserByName(username string, excluding uint) (user User, err error) { + err = db.Model(&User{}).Where("upper(username) == upper(?) AND id != ?", username, excluding).First(&user).Error + return +} + +func sanitizeUsername(username string) (string, error) { + if !validUsername.MatchString(username) || len(username) < minUsernameLength || len(username) > maxUsernameLength { + return "", errors.New("invalid username") + } + + return username, nil +} + +func sanitizeEmail(email string) (string, error) { + email = strings.ToLower(email) + + if !validEmail.MatchString(email) { + return "", fmt.Errorf("invalid email") + } + + return email, nil +} + +func login(w http.ResponseWriter, userID uint, remember bool) { + var duration time.Duration + if remember { + duration = durationWeek + } else { + duration = durationDay + } + + cookie, err := g.GenerateCookie(duration) + if err != nil { + http.Error(w, "Could not generate session cookie.", http.StatusInternalServerError) + } + + ks.Set("session:"+cookie.Value, userID, duration) + http.SetCookie(w, cookie) +} + +func loadEmailConfig() *email.Client { + address := os.Getenv("APP_SMTP_EMAIL") + password := os.Getenv("APP_SMTP_PASSWORD") + host := os.Getenv("APP_SMTP_HOST") + port := os.Getenv("APP_SMTP_PORT") + + if address == "" || password == "" || host == "" { + log.Println("Missing email configuration.") + return nil + } + + if port == "" { + port = "587" + } + + return email.NewClient(address, password, host, port) +} + +func sendEmail(mail email.Email) error { + if m == nil { + return errors.New("email client is not initialized") + } + return m.Send(mail) +} + +func sendResetEmail(address, token string) { + resetURL := fmt.Sprintf("%s/reset-password-confirm?token=%s", baseUrl, token) + err := sendEmail(email.Email{ + To: []string{address}, + Subject: "Reset password", + Body: fmt.Sprintf("Use the following link to reset your password:\n%s", resetURL), + }) + if err != nil { + log.Printf("Could not send reset email for %s. Link: %s", address, resetURL) + } +} + +func readSessionCookie(r *http.Request) (userID *uint, err error) { + cookie, err := r.Cookie("session_token") + if err != nil { + return + } + return ks.Get("session:" + cookie.Value) +} + +// Middleware to check if the user is logged in +func loginRequired(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := readSessionCookie(r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + ctx := context.WithValue(r.Context(), userContextKey, *userID) + next(w, r.WithContext(ctx)) + } +} + +func getLoggedUser(r *http.Request) (user User, ok bool) { + userID, ok := r.Context().Value(userContextKey).(uint) + db.Find(&user, userID) + return user, ok +}
A src/app/handlers.go

@@ -0,0 +1,167 @@

+package app + +import ( + "net/http" + "time" +) + +func getIndexHandler(w http.ResponseWriter, r *http.Request) { + xt.ExecuteTemplate(w, "index.tmpl", nil) +} + +func getProfileHandler(w http.ResponseWriter, r *http.Request) { + user, ok := getLoggedUser(r) + if !ok { + http.Error(w, "Could not find user in context.", http.StatusInternalServerError) + return + } + + xt.ExecuteTemplate(w, "profile.tmpl", map[string]interface{}{"User": user}) +} + +func getRegisterHandler(w http.ResponseWriter, r *http.Request) { + xt.ExecuteTemplate(w, "auth-register.tmpl", nil) +} + +func getLoginHandler(w http.ResponseWriter, r *http.Request) { + _, err := readSessionCookie(r) + if err != nil { + xt.ExecuteTemplate(w, "auth-login.tmpl", nil) + return + } + http.Redirect(w, r, "/profile", http.StatusFound) +} + +func getResetPasswordHandler(w http.ResponseWriter, r *http.Request) { + xt.ExecuteTemplate(w, "auth-reset_password.tmpl", nil) +} + +func postRegisterHandler(w http.ResponseWriter, r *http.Request) { + if !registrationEnabled { + http.Error(w, "Registration is currently disabled.", http.StatusForbidden) + return + } + + username, err := sanitizeUsername(r.FormValue("username")) + if err != nil { + http.Error(w, "Invalid username.", http.StatusBadRequest) + return + } + + email, err := sanitizeEmail(r.FormValue("email")) + if err != nil { + http.Error(w, "Invalid email.", http.StatusBadRequest) + return + } + + _, err = getUserByName(username, 0) + if err == nil { + http.Error(w, "This username is already registered.", http.StatusConflict) + return + } + + hashedPassword, salt, err := g.HashPassword(r.FormValue("password")) + if err != nil { + http.Error(w, "Invalid password.", http.StatusBadRequest) + return + } + + user := User{ + Username: username, + Email: email, + PasswordHash: hashedPassword, + Salt: salt, + } + + db.Create(&user) + if user.ID == 0 { + http.Error(w, "This email is already registered.", http.StatusConflict) + return + } + + login(w, user.ID, false) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func postLoginHandler(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + remember := r.FormValue("remember") + + user, err := getUserByName(username, 0) + + if err != nil || !g.CheckPassword(password, user.Salt, user.PasswordHash) { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + login(w, user.ID, remember == "on") + http.Redirect(w, r, "/login", http.StatusFound) +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, g.GenerateEmptyCookie()) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func postResetPasswordHandler(w http.ResponseWriter, r *http.Request) { + emailInput := r.FormValue("email") + + var user User + db.Where("email = ?", emailInput).First(&user) + + if user.ID == 0 { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + resetToken, err := g.GenerateRandomToken(32) + if err != nil { + http.Error(w, "Could not generate reset token.", http.StatusInternalServerError) + return + } + + ks.Set("reset:"+resetToken, user.ID, time.Hour) + sendResetEmail(user.Email, resetToken) + + http.Redirect(w, r, "/login", http.StatusFound) + +} + +func getResetPasswordConfirmHandler(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + _, err := ks.Get("reset:" + token) + if err != nil { + http.Error(w, "Token is invalid or expired.", http.StatusUnauthorized) + return + } + + xt.ExecuteTemplate(w, "auth-new_password.tmpl", nil) +} + +func postResetPasswordConfirmHandler(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + userID, err := ks.Get("reset:" + token) + if err != nil { + http.Error(w, "Token is invalid or expired.", http.StatusUnauthorized) + return + } + + var user User + db.First(&user, *userID) + + password := r.FormValue("password") + + hashedPassword, salt, err := g.HashPassword(password) + if err != nil { + http.Error(w, "Invalid password.", http.StatusBadRequest) + return + } + + user.PasswordHash = hashedPassword + user.Salt = salt + db.Save(&user) + ks.Delete(token) + + http.Redirect(w, r, "/login", http.StatusFound) +}
A src/app/init.go

@@ -0,0 +1,116 @@

+package app + +import ( + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/birabittoh/auth-boilerplate/src/auth" + "github.com/birabittoh/auth-boilerplate/src/email" + "github.com/birabittoh/myks" + "github.com/glebarez/sqlite" + "github.com/joho/godotenv" + "github.com/utking/extemplate" + "gorm.io/gorm" +) + +type key int + +type User struct { + gorm.Model + Username string `gorm:"unique"` + Email string `gorm:"unique"` + PasswordHash string + Salt string +} + +const ( + dataDir = "data" + dbName = "app.db" +) + +var ( + db *gorm.DB + g *auth.Auth + m *email.Client + xt *extemplate.Extemplate + + baseUrl string + port string + registrationEnabled = true + + ks = myks.New[uint](0) + durationDay = 24 * time.Hour + durationWeek = 7 * durationDay +) + +const userContextKey key = 0 + +func Main() { + err := godotenv.Load() + if err != nil { + log.Println("Error loading .env file") + } + + port = os.Getenv("APP_PORT") + if port == "" { + port = "3000" + } + + baseUrl = os.Getenv("APP_BASE_URL") + if baseUrl == "" { + baseUrl = "http://localhost:" + port + } + + e := strings.ToLower(os.Getenv("APP_REGISTRATION_ENABLED")) + if e == "false" || e == "0" { + registrationEnabled = false + } + + // Init auth and email + m = loadEmailConfig() + g = auth.NewAuth(os.Getenv("APP_PEPPER"), auth.DefaultMaxPasswordLength) + if g == nil { + log.Fatal("Could not init authentication.") + } + + os.MkdirAll(dataDir, os.ModePerm) + dbPath := filepath.Join(dataDir, dbName) + "?_pragma=foreign_keys(1)" + db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + db.AutoMigrate(&User{}) + + // Init template engine + xt = extemplate.New() + err = xt.ParseDir("templates", []string{".tmpl"}) + if err != nil { + log.Fatal(err) + } + + // Handle routes + http.HandleFunc("GET /", getIndexHandler) + http.HandleFunc("GET /profile", loginRequired(getProfileHandler)) + http.HandleFunc("GET /register", getRegisterHandler) + http.HandleFunc("GET /login", getLoginHandler) + http.HandleFunc("GET /reset-password", getResetPasswordHandler) + http.HandleFunc("GET /reset-password-confirm", getResetPasswordConfirmHandler) + http.HandleFunc("GET /logout", logoutHandler) + + http.HandleFunc("POST /login", postLoginHandler) + http.HandleFunc("POST /register", postRegisterHandler) + http.HandleFunc("POST /reset-password", postResetPasswordHandler) + http.HandleFunc("POST /reset-password-confirm", postResetPasswordConfirmHandler) + + http.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // Start serving + log.Println("Port: " + port) + log.Println("Server started: " + baseUrl) + log.Fatal(http.ListenAndServe(":"+port, nil)) +}
A src/auth/auth.go

@@ -0,0 +1,109 @@

+package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "time" + "unicode" + + "golang.org/x/crypto/bcrypt" +) + +type Auth struct { + Pepper string + spicesLength int +} + +const ( + minPasswordLength = 6 + maxHashLength = 72 + DefaultMaxPasswordLength = 56 // leaves 16 bytes for salt and pepper +) + +func NewAuth(pepper string, maxPasswordLength uint) *Auth { + if maxPasswordLength > 70 { + return nil + } + + hash, err := bcrypt.GenerateFromPassword([]byte(pepper), bcrypt.DefaultCost) + if err != nil { + return nil + } + + spicesLength := int(maxHashLength-maxPasswordLength) / 2 + return &Auth{ + Pepper: hex.EncodeToString(hash)[:spicesLength], + spicesLength: spicesLength, + } +} + +func (g Auth) HashPassword(password string) (hashedPassword, salt string, err error) { + if !isASCII(password) || len(password) < minPasswordLength { + err = errors.New("invalid password") + return + } + + salt, err = g.GenerateRandomToken(g.spicesLength) + if err != nil { + return + } + salt = salt[:g.spicesLength] + + bytesPassword, err := bcrypt.GenerateFromPassword([]byte(password+salt+g.Pepper), bcrypt.DefaultCost) + if err != nil { + return + } + + hashedPassword = string(bytesPassword) + return +} + +func (g Auth) CheckPassword(password, salt, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+salt+g.Pepper)) + return err == nil +} + +func (g Auth) GenerateRandomToken(n int) (string, error) { + token := make([]byte, n) + _, err := rand.Read(token) + if err != nil { + return "", err + } + return hex.EncodeToString(token), nil +} + +func (g Auth) GenerateCookie(duration time.Duration) (*http.Cookie, error) { + sessionToken, err := g.GenerateRandomToken(32) + if err != nil { + return nil, err + } + + return &http.Cookie{ + Name: "session_token", + Value: sessionToken, + Expires: time.Now().Add(duration), + Path: "/", + HttpOnly: true, + Secure: true, + }, nil +} + +func (g Auth) GenerateEmptyCookie() *http.Cookie { + return &http.Cookie{ + Name: "session_token", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + Path: "/", + } +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +}
A src/email/email.go

@@ -0,0 +1,44 @@

+package email + +import ( + "net/smtp" +) + +// EmailConfig rappresenta la configurazione necessaria per inviare un'email +type Client struct { + email string + + auth smtp.Auth + addr string +} + +// Email rappresenta il contenuto dell'email +type Email struct { + To []string + Subject string + Body string +} + +func NewClient(senderEmail, password, host, port string) *Client { + return &Client{ + addr: host + ":" + port, + auth: smtp.PlainAuth("", senderEmail, password, host), + } +} + +// Send invia l'email utilizzando la configurazione fornita +func (config *Client) Send(email Email) error { + // Costruzione del messaggio + msg := "From: " + config.email + "\n" + + "To: " + email.To[0] + "\n" + + "Subject: " + email.Subject + "\n\n" + + email.Body + + // Invio email tramite il server SMTP + err := smtp.SendMail(config.addr, config.auth, config.email, email.To, []byte(msg)) + if err != nil { + return err + } + + return nil +}
A static/style.css

@@ -0,0 +1,13 @@

+label { + display: grid; + grid-template-columns: auto auto; + align-items: center; + justify-items: start; + align-content: center; + justify-content: space-between; + max-width: 350px; +} + +label > input { + max-width: 250px; +}
A templates/auth-login.tmpl

@@ -0,0 +1,24 @@

+{{ extends "auth.tmpl" }} + +{{define "title" -}}Login{{end}} + +{{define "auth" -}} + <h1>Login</h1> + <form method="post" action="/login"> + <label> + <span>Username:</span> + <input type="text" name="username" autocomplete="off" placeholder="Username" required /> + </label> + <label> + <span>Password:</span> + <input type="password" name="password" placeholder="Password" required /> + </label> + <label> + <span>Remember me:</span> + <input type="checkbox" name="remember" /> + </label> + <input type="submit" value="Login" /> + </form> + <a href="/register">Sign up</a><br /> + <a href="/reset-password">Reset password</a> +{{end}}
A templates/auth-new_password.tmpl

@@ -0,0 +1,14 @@

+{{ extends "auth.tmpl" }} + +{{define "title" -}}Reset password{{end}} + +{{define "auth" -}} +<h1>Reset password</h1> +<form method="post"> + <label> + <span>New password:</span> + <input type="password" name="password" placeholder="Max 56 chars, ASCII only" required /> + </label> + <input type="submit" value="Reset password" /> +</form> +{{end}}
A templates/auth-register.tmpl

@@ -0,0 +1,25 @@

+{{ extends "auth.tmpl" }} + +{{define "title" -}}Sign up{{end}} + +{{define "auth" -}} +<h1>Sign up</h1> +<form method="post" action="/register"> + <label> + <span>Username:</span> + <input type="text" name="username" placeholder="[a-z0-9._-]" required /> + </label> + <label> + <span>Email:</span> + <input type="email" name="email" placeholder="Email" required /> + </label> + <label> + <span>Password:</span> + <input type="password" name="password" placeholder="Max 56 chars, ASCII only" required /> + </label> + <input type="submit" value="Sign up" /> +</form> +<a href="/login">Login</a><br /> +<a href="/reset-password">Reset password</a> +</form> +{{end}}
A templates/auth-reset_password.tmpl

@@ -0,0 +1,16 @@

+{{ extends "auth.tmpl" }} + +{{define "title" -}}Reset password{{end}} + +{{define "auth" -}} +<h1>Reset password</h1> +<form method="post" action="/reset-password"> + <label> + <span>Email:</span> + <input type="email" name="email" placeholder="Email" required /> + </label> + <input type="submit" value="Reset password" /> +</form> +<a href="/login">Login</a><br /> +<a href="/register">Sign up</a> +{{end}}
A templates/auth.tmpl

@@ -0,0 +1,9 @@

+{{ extends "base.tmpl" }} + +{{define "content" -}} +<center> +{{block "auth" .}} + <h1>Auth template</h1> +{{end}} +</center> +{{end}}
A templates/base.tmpl

@@ -0,0 +1,18 @@

+<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{block "title" .}}Page title{{end}}</title> + <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> + <link rel="stylesheet" href="/static/style.css"> +</head> + +<body> + {{block "content" .}} + <h1>Base template</h1> + {{end}} +</body> + +</html>
A templates/index.tmpl

@@ -0,0 +1,9 @@

+{{ extends "base.tmpl" }} + +{{define "title" -}}Auth boilerplate{{end}} + +{{define "content" -}} + <h1>Auth boilerplate</i>!</h1> + <p>This page is accessible to anyone.</p> + <a href="/profile" class="button">Start</a> +{{end}}
A templates/profile.tmpl

@@ -0,0 +1,9 @@

+{{ extends "base.tmpl" }} + +{{define "title" -}}User profile{{end}} + +{{define "content" -}} + <h1>Welcome, <i>{{.User.Username}}</i>!</h1> + <p>This page is only accessible by users who have logged in.</p> + <a href="/logout">Logout</a> +{{end}}