wannpassts/backend/internal/httpapi/auth.go
Tronax cb30223fd4
feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
2026-08-25 18:50:28 +02:00

289 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"wannpassts/internal/db"
)
const tokenTTL = 7 * 24 * time.Hour
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Timezone string `json:"timezone"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Name = strings.TrimSpace(in.Name)
if !validEmail(in.Email) {
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
return
}
if len(in.Password) < 8 || len(in.Password) > 200 {
jsonError(w, http.StatusBadRequest, "Das Passwort muss 8200 Zeichen lang sein")
return
}
if in.Name == "" || len(in.Name) > 80 {
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben")
return
}
tz := in.Timezone
if tz == "" {
tz = "Europe/Berlin"
}
if _, err := time.LoadLocation(tz); err != nil {
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
return
}
if _, err := s.DB.UserByEmail(in.Email); err == nil {
jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
return
}
slug, err := s.uniqueSlug()
if err != nil {
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
return
}
u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz}
if err := s.DB.CreateUser(u); err != nil {
jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden")
return
}
token, err := s.signJWT(u.ID, tokenTTL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)})
}
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email)))
if err != nil {
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
return
}
token, err := s.signJWT(u.ID, tokenTTL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)})
}
func (s *Server) Me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))})
}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
u := userFrom(r)
var in struct {
Name *string `json:"name"`
Timezone *string `json:"timezone"`
SlotMinutes *int `json:"slot_minutes"`
DayStartMin *int `json:"day_start_min"`
DayEndMin *int `json:"day_end_min"`
HorizonDays *int `json:"horizon_days"`
Durations []int `json:"durations"`
Weekdays []int `json:"weekdays"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" || len(name) > 80 {
jsonError(w, http.StatusBadRequest, "Name muss 180 Zeichen haben")
return
}
u.Name = name
}
if in.Timezone != nil {
if _, err := time.LoadLocation(*in.Timezone); err != nil {
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
return
}
u.Timezone = *in.Timezone
}
if in.SlotMinutes != nil {
if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 {
jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5180 Minuten sein")
return
}
u.SlotMinutes = *in.SlotMinutes
}
if in.DayStartMin != nil {
if *in.DayStartMin < 0 || *in.DayStartMin > 1439 {
jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig")
return
}
u.DayStartMin = *in.DayStartMin
}
if in.DayEndMin != nil {
if *in.DayEndMin < 1 || *in.DayEndMin > 1440 {
jsonError(w, http.StatusBadRequest, "Tagesende ungültig")
return
}
u.DayEndMin = *in.DayEndMin
}
if u.DayStartMin >= u.DayEndMin {
jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen")
return
}
if in.HorizonDays != nil {
if *in.HorizonDays < 1 || *in.HorizonDays > 120 {
jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1120 Tage sein")
return
}
u.HorizonDays = *in.HorizonDays
}
if in.Durations != nil {
durations, err := cleanDurations(in.Durations)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
u.Durations = intsCSV(durations)
}
if in.Weekdays != nil {
weekdays, err := cleanWeekdays(in.Weekdays)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
u.Weekdays = intsCSV(weekdays)
}
if err := s.DB.UpdateUserSettings(u); err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
}
func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) {
u := userFrom(r)
slug, err := s.uniqueSlug()
if err != nil {
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
return
}
if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
u.Slug = slug
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
}
func (s *Server) uniqueSlug() (string, error) {
for i := 0; i < 8; i++ {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", err
}
slug := base64.RawURLEncoding.EncodeToString(b)
exists, err := s.DB.SlugExists(slug)
if err != nil {
return "", err
}
if !exists {
return slug, nil
}
}
return "", http.ErrBodyNotAllowed
}
func validEmail(s string) bool {
if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 {
return false
}
at := strings.IndexByte(s, '@')
local, domain := s[:at], s[at+1:]
if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
return !strings.ContainsAny(s, " \t")
}
func cleanDurations(list []int) ([]int, error) {
if len(list) == 0 || len(list) > 8 {
return nil, errNew("18 Dauerangaben benötigt")
}
seen := map[int]bool{}
var out []int
for _, v := range list {
if v < 5 || v > 480 {
return nil, errNew("Dauern müssen 5480 Minuten sein")
}
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
func cleanWeekdays(list []int) ([]int, error) {
if len(list) == 0 || len(list) > 7 {
return nil, errNew("Mindestens ein Buchungstag nötig")
}
seen := map[int]bool{}
var out []int
for _, v := range list {
if v < 1 || v > 7 {
return nil, errNew("Wochentage müssen 1 (Mo) 7 (So) sein")
}
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
func errNew(msg string) error { return &simpleError{msg} }
type simpleError struct{ msg string }
func (e *simpleError) Error() string { return e.msg }