diff --git a/cmd/main.go b/cmd/main.go index f596df9..99c539b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,9 +37,16 @@ func main() { mux := http.NewServeMux() // Routes - mux.HandleFunc("/login", handler.LoginHandler) + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static")))) + + mux.HandleFunc("GET /{$}", handler.HomeHandler) + mux.HandleFunc("GET /login", handler.LoginPageHandler) mux.HandleFunc("/user", handler.UserHandler) - mux.HandleFunc("/signup", handler.SignUpHandler) + mux.HandleFunc("/signup", handler.SignUpPageHandler) + + mux.HandleFunc("POST /api/login", handler.LoginHandler) + mux.HandleFunc("POST /api/logout", handler.LogoutHandler) + mux.HandleFunc("POST /api/signup", handler.SignUpHandler) // Apply middleware (CORS first, then logging) muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux)) diff --git a/internal/auth/session.go b/internal/auth/session.go index 0f0a7b6..e043b83 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -37,20 +37,3 @@ func HashSecret(secret string) []byte { func CheckExpiration(expirarion time.Time) bool { return time.Since(expirarion).Seconds() >= sessionExpiresInSeconds } - -func ParseCookies(cookieHeader string) map[string]string { - cookies := make(map[string]string) - if cookieHeader == "" { - return cookies - } - - pairs := strings.Split(cookieHeader, ";") - for _, pair := range pairs { - pair = strings.TrimSpace(pair) - parts := strings.SplitN(pair, "=", 2) - if len(parts) == 2 { - cookies[parts[0]] = parts[1] - } - } - return cookies -} diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index 3102223..7b43246 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -4,6 +4,9 @@ import ( "context" "go-backend/internal/database/sqlc" "go-backend/internal/services" + "log" + "net/http" + "text/template" ) // UserService and AuthService are defined here, on the consumer side, so @@ -19,6 +22,7 @@ type UserService interface { type AuthService interface { CreateSession(ctx context.Context, userID string) (*services.SessionWithToken, error) ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, error) + DeleteSession(ctx context.Context, token string) error } type Handler struct { @@ -32,3 +36,117 @@ func NewHandler(users UserService, auth AuthService) *Handler { auth: auth, } } + +func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) + + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if session == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + files := []string{ + "./ui/html/base.tmpl", + "./ui/html/pages/index.tmpl", + } + + ts, err := template.ParseFiles(files...) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + err = ts.ExecuteTemplate(w, "base", nil) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + + if cookie != nil { + session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) + + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if session != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + } + + files := []string{ + "./ui/html/base.tmpl", + "./ui/html/pages/login.tmpl", + } + + ts, err := template.ParseFiles(files...) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + err = ts.ExecuteTemplate(w, "base", nil) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + + if cookie != nil { + session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) + + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if session != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + } + + files := []string{ + "./ui/html/base.tmpl", + "./ui/html/pages/signup.tmpl", + } + + ts, err := template.ParseFiles(files...) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + err = ts.ExecuteTemplate(w, "base", nil) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/internal/handlers/login.go b/internal/handlers/login.go index 81e005c..f3ac545 100644 --- a/internal/handlers/login.go +++ b/internal/handlers/login.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "log" "net/http" @@ -9,23 +8,19 @@ import ( ) type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` + Username string + Password string } func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) return } - var req LoginRequest - - decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&req) - if err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return + req := LoginRequest{ + Username: r.PostFormValue("username"), + Password: r.PostFormValue("password"), } if req.Username == "" || req.Password == "" { @@ -67,6 +62,38 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, cookie) + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Success")) +} + +func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + err = h.auth.DeleteSession(r.Context(), cookie.Value) + + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + cookie = &http.Cookie{ + Name: "session", + Value: "", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + Path: "/", + } + + http.SetCookie(w, cookie) + + w.Header().Set("HX-Redirect", "/") w.WriteHeader(http.StatusOK) w.Write([]byte("Success")) } diff --git a/internal/handlers/login_test.go b/internal/handlers/login_test.go deleted file mode 100644 index dcc326e..0000000 --- a/internal/handlers/login_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/base64" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "go-backend/internal/auth" - "go-backend/internal/database" - "go-backend/internal/services" - - "golang.org/x/crypto/argon2" -) - -// fakeUsers and fakeAuth satisfy the UserService and AuthService interfaces -// declared in handler.go. No database, no network, no real hashing work — -// each test controls exactly what the "service layer" returns. - -type fakeUsers struct { - user *database.User - err error -} - -func (f *fakeUsers) FindByUsername(username string) (*database.User, error) { - return f.user, f.err -} - -func (f *fakeUsers) FindByID(id string) (*database.User, error) { - return f.user, f.err -} - -type fakeAuth struct { - session *services.SessionWithToken - err error -} - -func (f *fakeAuth) CreateSession(userID string) (*auth.SessionWithToken, error) { - return f.session, f.err -} - -func (f *fakeAuth) ValidateSessionToken(token string) (*auth.Session, error) { - return nil, nil -} - -// hashPassword builds a real Argon2id hash in the format auth.VerifyPassword -// expects, so the "correct password" test case exercises the real -// verification logic rather than a stub. -func hashPassword(password, salt string) string { - saltBytes := []byte(salt) - hash := argon2.IDKey([]byte(password), saltBytes, 3, 65536, 4, 32) - return "$argon2id$v=19$m=65536,t=3,p=4$" + - base64.RawStdEncoding.EncodeToString(saltBytes) + "$" + - base64.RawStdEncoding.EncodeToString(hash) -} - -func TestLoginHandler(t *testing.T) { - validUser := &database.User{ID: "user-1", Username: "alice", PasswordHash: hashPassword("correct-horse", "somesalt16bytes!")} - - tests := []struct { - name string - body string - users UserService - auth AuthService - wantStatus int - }{ - { - name: "successful login sets session cookie", - body: `{"username":"alice","password":"correct-horse"}`, - users: &fakeUsers{user: validUser}, - auth: &fakeAuth{session: &services.SessionWithToken{Token: "abc.def"}}, - wantStatus: http.StatusOK, - }, - { - name: "wrong password rejected", - body: `{"username":"alice","password":"wrong-password"}`, - users: &fakeUsers{user: validUser}, - auth: &fakeAuth{}, - wantStatus: http.StatusBadRequest, - }, - { - name: "unknown username rejected", - body: `{"username":"nobody","password":"whatever"}`, - users: &fakeUsers{user: nil}, - auth: &fakeAuth{}, - wantStatus: http.StatusBadRequest, - }, - { - name: "missing fields rejected before hitting services", - body: `{"username":"","password":""}`, - users: &fakeUsers{err: errors.New("should never be called")}, - auth: &fakeAuth{}, - wantStatus: http.StatusBadRequest, - }, - { - name: "user lookup failure returns 500", - body: `{"username":"alice","password":"correct-horse"}`, - users: &fakeUsers{err: errors.New("connection refused")}, - auth: &fakeAuth{}, - wantStatus: http.StatusInternalServerError, - }, - { - name: "session creation failure returns 500", - body: `{"username":"alice","password":"correct-horse"}`, - users: &fakeUsers{user: validUser}, - auth: &fakeAuth{err: errors.New("disk full")}, - wantStatus: http.StatusInternalServerError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := NewHandler(tt.users, tt.auth) - - req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(tt.body)) - w := httptest.NewRecorder() - - h.LoginHandler(w, req) - - if w.Code != tt.wantStatus { - t.Fatalf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String()) - } - - if tt.wantStatus == http.StatusOK { - resp := w.Result() - cookies := resp.Cookies() - if len(cookies) != 1 || cookies[0].Name != "session" || cookies[0].Value != "abc.def" { - t.Fatalf("expected session cookie with token abc.def, got %+v", cookies) - } - } - }) - } -} - -func TestLoginHandlerRejectsInvalidJSON(t *testing.T) { - h := NewHandler(&fakeUsers{}, &fakeAuth{}) - - req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(`not-json`)) - w := httptest.NewRecorder() - - h.LoginHandler(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) - } -} - -func TestLoginHandlerRejectsWrongMethod(t *testing.T) { - h := NewHandler(&fakeUsers{}, &fakeAuth{}) - - req := httptest.NewRequest(http.MethodGet, "/login", nil) - w := httptest.NewRecorder() - - h.LoginHandler(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) - } -} diff --git a/internal/handlers/signup.go b/internal/handlers/signup.go index 9df0050..feb0919 100644 --- a/internal/handlers/signup.go +++ b/internal/handlers/signup.go @@ -1,17 +1,16 @@ package handlers import ( - "encoding/json" "go-backend/internal/auth" "go-backend/internal/database/sqlc" "log" "net/http" ) -type SignUpRequest struct { - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` +type SignupRequest struct { + Username string + Password string + Email string } func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) { @@ -20,13 +19,10 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) { return } - var req SignUpRequest - - decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&req) - if err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return + req := SignupRequest{ + Username: r.PostFormValue("username"), + Password: r.PostFormValue("password"), + Email: r.PostFormValue("email"), } if req.Username == "" || req.Password == "" || req.Email == "" { @@ -78,6 +74,7 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, cookie) + w.Header().Set("HX-Redirect", "/") w.WriteHeader(http.StatusOK) w.Write([]byte("Success")) } diff --git a/internal/handlers/user.go b/internal/handlers/user.go index 0480a69..f31d65c 100644 --- a/internal/handlers/user.go +++ b/internal/handlers/user.go @@ -3,8 +3,6 @@ package handlers import ( "encoding/json" "net/http" - - "go-backend/internal/auth" ) type UserResponse struct { @@ -20,15 +18,14 @@ func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) { } // Get session token from cookie - cookies := auth.ParseCookies(r.Header.Get("Cookie")) - sessionToken, exists := cookies["session"] - if !exists { + cookie, err := r.Cookie("session") + if err != nil { http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized) return } // Validate session - session, err := h.auth.ValidateSessionToken(r.Context(), sessionToken) + session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return diff --git a/internal/services/auth.go b/internal/services/auth.go index e16e336..150483e 100644 --- a/internal/services/auth.go +++ b/internal/services/auth.go @@ -94,3 +94,16 @@ func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (* return &session, nil } + +func (s *AuthService) DeleteSession(ctx context.Context, token string) error { + tokenParts := strings.Split(token, ".") + if len(tokenParts) != 2 { + return nil + } + + sessionID := tokenParts[0] + + err := s.queries.DeleteSessionById(ctx, sessionID) + + return err +} diff --git a/ui/html/base.tmpl b/ui/html/base.tmpl new file mode 100644 index 0000000..28899de --- /dev/null +++ b/ui/html/base.tmpl @@ -0,0 +1,28 @@ +{{define "base"}} + + +
+ ++ Don't have an account? Sign up here +
++ Already have an account? Login here +
+