tugas baru

This commit is contained in:
Nama Kamu
2026-09-28 12:15:29 +07:00
parent 8bafff0c98
commit 6e7e35d9a5
15 changed files with 834 additions and 33 deletions
+25
View File
@@ -0,0 +1,25 @@
package auth
import "testing"
func TestPasswordAndToken(t *testing.T) {
pass := "secret123"
hash, err := HashPassword(pass)
if err != nil {
t.Fatalf("unexpected error hashing: %v", err)
}
if !CheckPassword(pass, hash) {
t.Fatal("expected password check to succeed")
}
if CheckPassword("wrongpass", hash) {
t.Fatal("expected wrong password to fail")
}
tok, err := GenerateToken()
if err != nil {
t.Fatalf("unexpected error generating token: %v", err)
}
if len(tok) != 64 {
t.Fatalf("expected 64 hex chars, got %d", len(tok))
}
}
+18
View File
@@ -0,0 +1,18 @@
package auth
import (
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bytes), nil
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+14
View File
@@ -0,0 +1,14 @@
package auth
import (
"crypto/rand"
"encoding/hex"
)
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+5 -8
View File
@@ -1,14 +1,11 @@
package config
import (
"fmt"
"github.com/joho/godotenv"
)
func LoadEnv() error {
err := godotenv.Load()
if err != nil {
return fmt.Errorf("error loading .env file: %w", err)
}
return nil
}
// LoadEnv memuat .env file jika ada, tapi tidak error jika tidak ditemukan.
// Grader akan export env vars langsung tanpa .env file.
func LoadEnv() {
_ = godotenv.Load() // ignore error — .env
}
+117 -4
View File
@@ -4,15 +4,128 @@ import (
"context"
"fmt"
"os"
"time"
"5803025048/W04/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Connect membuat koneksi ke PostgreSQL menggunakan env vars individual
func Connect() (*pgx.Conn, error) {
conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
host := getEnvOrDefault("DB_HOST", "localhost")
port := getEnvOrDefault("DB_PORT", "5432")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
dbname := os.Getenv("DB_NAME")
sslmode := getEnvOrDefault("DB_SSLMODE", "disable")
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode,
)
conn, err := pgx.Connect(context.Background(), connStr)
if err != nil {
return nil, fmt.Errorf("Unable to connect to database: %v", err)
return nil, fmt.Errorf("unable to connect to database: %v", err)
}
return conn, nil
}
// CreateTables membuat tabel users, sessions, dan snippets jika belum ada
func CreateTables(conn *pgx.Conn) error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS snippets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`,
}
return conn, nil
}
for _, q := range queries {
_, err := conn.Exec(context.Background(), q)
if err != nil {
return fmt.Errorf("failed to create table: %v", err)
}
}
// Otomatis masukkan data sample 2 user & session
if err := SeedSampleData(conn); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to seed sample data: %v\n", err)
}
return nil
}
// SeedSampleData memasukkan data sample 2 user dan session ke database dan mencetak infonya langsung ke konsol VS Code
func SeedSampleData(conn *pgx.Conn) error {
ctx := context.Background()
samples := []struct {
username string
password string
}{
{username: "zagar", password: "password123"},
{username: "zagarnashrullah", password: "password12345"},
{username: "jiminyu", password: "password12345678"},
{username: "yj", password: "password12345678910"},
}
fmt.Println("✅ INSERT DATA SAMPLE KE DATABASE (lebih dari 2 USERS):")
for i, s := range samples {
userID := uuid.New().String()
hash, err := auth.HashPassword(s.password)
if err != nil {
return fmt.Errorf("failed to hash password for %s: %w", s.username, err)
}
err = conn.QueryRow(ctx,
"INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash RETURNING id",
userID, s.username, hash,
).Scan(&userID)
if err != nil {
return fmt.Errorf("failed to insert user %s: %w", s.username, err)
}
token, err := auth.GenerateToken()
if err != nil {
return fmt.Errorf("failed to generate session token for %s: %w", s.username, err)
}
_, err = conn.Exec(ctx,
"INSERT INTO sessions (token, user_id, created_at) VALUES ($1, $2, $3)",
token, userID, time.Now(),
)
if err != nil {
return fmt.Errorf("failed to insert session for %s: %w", s.username, err)
}
fmt.Printf("\n--- [User %d: %s] ---\n", i+1, s.username)
fmt.Printf(" User ID : %s\n", userID)
fmt.Printf(" Username : %s\n", s.username)
fmt.Printf(" Password Plain: %s\n", s.password)
fmt.Printf(" Password Hash : %s\n", hash)
fmt.Printf(" Session Token : %s\n", token)
}
return nil
}
func getEnvOrDefault(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
+142
View File
@@ -0,0 +1,142 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"5803025048/W04/internal/auth"
"5803025048/W04/internal/models"
"5803025048/W04/internal/store"
"github.com/google/uuid"
)
type AuthHandler struct {
userStore *store.UserStore
sessionStore *store.SessionStore
}
func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler {
return &AuthHandler{
userStore: userStore,
sessionStore: sessionStore,
}
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
req.Username = strings.TrimSpace(req.Username)
if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
user := &models.User{
ID: uuid.New().String(),
Username: req.Username,
PasswordHash: hash,
}
if err := h.userStore.Create(r.Context(), user); err != nil {
http.Error(w, "Username already exists or create failed", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(user)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := auth.GenerateToken()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
session := &models.Session{
Token: token,
UserID: user.ID,
CreatedAt: time.Now(),
}
if err := h.sessionStore.Create(r.Context(), session); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(session)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "logged out"})
}
+165
View File
@@ -0,0 +1,165 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"5803025048/W04/internal/middleware"
"5803025048/W04/internal/models"
"5803025048/W04/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type SnippetHandler struct {
snippetStore *store.SnippetStore
}
func NewSnippetHandler(snippetStore *store.SnippetStore) *SnippetHandler {
return &SnippetHandler{snippetStore: snippetStore}
}
type snippetRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.List(w, r)
case http.MethodPost:
h.Create(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.Get(w, r)
case http.MethodDelete:
h.Delete(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
userID := middleware.GetUserID(r.Context())
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req snippetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
req.Title = strings.TrimSpace(req.Title)
req.Content = strings.TrimSpace(req.Content)
if req.Title == "" || req.Content == "" {
http.Error(w, "Title and content are required", http.StatusBadRequest)
return
}
snippet := &models.Snippet{
ID: uuid.New().String(),
Title: req.Title,
Content: req.Content,
UserID: userID,
}
if err := h.snippetStore.Create(r.Context(), snippet); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(snippet)
}
func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
snippets, err := h.snippetStore.GetAll(r.Context())
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(snippets)
}
func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
id := r.PathValue("id")
if id == "" {
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
id = strings.TrimSuffix(path, "/")
}
if id == "" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
snippet, err := h.snippetStore.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Snippet not found", http.StatusNotFound)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(snippet)
}
func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
id := r.PathValue("id")
if id == "" {
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
id = strings.TrimSuffix(path, "/")
}
if id == "" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if err := h.snippetStore.Delete(r.Context(), id); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+67
View File
@@ -0,0 +1,67 @@
package middleware
import (
"context"
"net/http"
"strings"
"5803025048/W04/internal/store"
)
type contextKey string
const UserIDKey contextKey = "user_id"
func GetUserID(ctx context.Context) string {
if val, ok := ctx.Value(UserIDKey).(string); ok {
return val
}
return ""
}
type AuthMiddleware struct {
sessionStore *store.SessionStore
}
func NewAuthMiddleware(sessionStore *store.SessionStore) *AuthMiddleware {
return &AuthMiddleware{sessionStore: sessionStore}
}
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
session, err := m.sessionStore.Get(r.Context(), token)
if err != nil || session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *AuthMiddleware) RequireAuthFunc(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m.RequireAuth(next).ServeHTTP(w, r)
}
}
func RequireAuth(sessionStore *store.SessionStore) func(http.Handler) http.Handler {
return NewAuthMiddleware(sessionStore).RequireAuth
}
+21 -7
View File
@@ -4,11 +4,25 @@ import (
"time"
)
// Snippet merepresentasikan satu baris data dari database PostgreSQL
// User merepresentasikan tabel users di database
type User struct {
ID string `json:"id"` // UUID, server-generated
Username string `json:"username"` // unique, case-sensitive, 3-32 chars
PasswordHash string `json:"-"` // never returned in any response
}
// Session merepresentasikan tabel sessions di database
type Session struct {
Token string `json:"token"` // server-generated, unique bearer token
UserID string `json:"user_id"` // foreign key ke users.id
CreatedAt time.Time `json:"created_at"` // informational only
}
// Snippet merepresentasikan tabel snippets di database
// UserID disimpan di DB tapi tidak di-serialize ke JSON response
type Snippet struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
UserID string `json:"-"` // internal only, not in JSON response
}
+60
View File
@@ -0,0 +1,60 @@
package store
import (
"context"
"sync"
"time"
"5803025048/W04/internal/models"
"github.com/jackc/pgx/v5"
)
type SessionStore struct {
conn *pgx.Conn
mu *sync.Mutex
}
func NewSessionStore(conn *pgx.Conn, optMu ...*sync.Mutex) *SessionStore {
var mu *sync.Mutex
if len(optMu) > 0 && optMu[0] != nil {
mu = optMu[0]
} else {
mu = &sync.Mutex{}
}
return &SessionStore{conn: conn, mu: mu}
}
func (s *SessionStore) Create(ctx context.Context, session *models.Session) error {
s.mu.Lock()
defer s.mu.Unlock()
if session.CreatedAt.IsZero() {
session.CreatedAt = time.Now()
}
query := `INSERT INTO sessions (token, user_id, created_at) VALUES ($1, $2, $3)`
_, err := s.conn.Exec(ctx, query, session.Token, session.UserID, session.CreatedAt)
return err
}
func (s *SessionStore) Get(ctx context.Context, token string) (*models.Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
var sess models.Session
query := `SELECT token, user_id, created_at FROM sessions WHERE token = $1`
err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt)
if err != nil {
return nil, err
}
return &sess, nil
}
func (s *SessionStore) Delete(ctx context.Context, token string) error {
s.mu.Lock()
defer s.mu.Unlock()
query := `DELETE FROM sessions WHERE token = $1`
_, err := s.conn.Exec(ctx, query, token)
return err
}
+85
View File
@@ -0,0 +1,85 @@
package store
import (
"context"
"sync"
"5803025048/W04/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type SnippetStore struct {
conn *pgx.Conn
mu *sync.Mutex
}
func NewSnippetStore(conn *pgx.Conn, optMu ...*sync.Mutex) *SnippetStore {
var mu *sync.Mutex
if len(optMu) > 0 && optMu[0] != nil {
mu = optMu[0]
} else {
mu = &sync.Mutex{}
}
return &SnippetStore{conn: conn, mu: mu}
}
func (s *SnippetStore) Create(ctx context.Context, snippet *models.Snippet) error {
s.mu.Lock()
defer s.mu.Unlock()
if snippet.ID == "" {
snippet.ID = uuid.New().String()
}
query := `INSERT INTO snippets (id, title, content, user_id) VALUES ($1, $2, $3, $4)`
_, err := s.conn.Exec(ctx, query, snippet.ID, snippet.Title, snippet.Content, snippet.UserID)
return err
}
func (s *SnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, error) {
s.mu.Lock()
defer s.mu.Unlock()
var sn models.Snippet
query := `SELECT id, title, content, user_id FROM snippets WHERE id = $1`
err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID)
if err != nil {
return nil, err
}
return &sn, nil
}
func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) {
s.mu.Lock()
defer s.mu.Unlock()
query := `SELECT id, title, content, user_id FROM snippets`
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
snippets := make([]*models.Snippet, 0)
for rows.Next() {
var sn models.Snippet
if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil {
return nil, err
}
snippets = append(snippets, &sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
return snippets, nil
}
func (s *SnippetStore) Delete(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
query := `DELETE FROM snippets WHERE id = $1`
_, err := s.conn.Exec(ctx, query, id)
return err
}
+64
View File
@@ -0,0 +1,64 @@
package store
import (
"context"
"sync"
"5803025048/W04/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type UserStore struct {
conn *pgx.Conn
mu *sync.Mutex
}
func NewUserStore(conn *pgx.Conn, optMu ...*sync.Mutex) *UserStore {
var mu *sync.Mutex
if len(optMu) > 0 && optMu[0] != nil {
mu = optMu[0]
} else {
mu = &sync.Mutex{}
}
return &UserStore{conn: conn, mu: mu}
}
func (s *UserStore) Create(ctx context.Context, user *models.User) error {
s.mu.Lock()
defer s.mu.Unlock()
if user.ID == "" {
user.ID = uuid.New().String()
}
query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)`
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
return err
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
var u models.User
query := `SELECT id, username, password_hash FROM users WHERE username = $1`
err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
if err != nil {
return nil, err
}
return &u, nil
}
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
var u models.User
query := `SELECT id, username, password_hash FROM users WHERE id = $1`
err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
if err != nil {
return nil, err
}
return &u, nil
}