diff --git a/api/swagger.yaml b/api/swagger.yaml index 19f857d..44d6a0d 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -12,6 +12,49 @@ servers: - url: 'http://localhost:4102' description: Local server paths: + /api/users/login: + post: + tags: + - Users + summary: Login user + description: Authenticates a user by user_name and password, starts a new session, and returns a session cookie. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_name: + type: string + example: exampleUser + password: + type: string + format: password + example: examplePass + responses: + 200: + description: User logged in successfully. A session cookie is set. + headers: + Set-Cookie: + description: Session cookie which needs to be included in subsequent requests. + schema: + type: string + example: 'session_id=abc123; Path=/; Max-Age=345600; HttpOnly' + content: + application/json: + schema: + type: object + properties: + data: + type: string + example: 'User logged in' + 400: + description: Invalid request body + 401: + description: Invalid user_name or password + 500: + description: Internal Server Error /api/users: get: tags: diff --git a/go.mod b/go.mod index 0947fc1..6bffa5b 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,12 @@ require ( github.com/gorilla/mux v1.8.1 github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/lib/pq v1.2.0 + golang.org/x/crypto v0.22.0 ) require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/joho/godotenv v1.5.1 // indirect - golang.org/x/crypto v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect ) diff --git a/internal/app/api/session.go b/internal/app/api/session.go new file mode 100644 index 0000000..42c3e84 --- /dev/null +++ b/internal/app/api/session.go @@ -0,0 +1,66 @@ +package api + +import ( + "ctf01d/internal/app/repository" + api_helpers "ctf01d/internal/app/utils" + "database/sql" + "encoding/json" + "net/http" +) + +type RequestLogin struct { + Username string `json:"user_name"` + Password string `json:"password"` +} + +func LoginSessionHandler(db *sql.DB, w http.ResponseWriter, r *http.Request) { + var req RequestLogin + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + userRepo := repository.NewUserRepository(db) + user, err := userRepo.GetByUserName(r.Context(), req.Username) + if err != nil || !api_helpers.CheckPasswordHash(req.Password, user.PasswordHash) { + api_helpers.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid password or user"}) + return + } + + sessionRepo := repository.NewSessionRepository(db) + sessionId, err := sessionRepo.StoreSessionInDB(r.Context(), user.Id) + if err != nil { + http.Error(w, "Failed to store session in DB", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + HttpOnly: true, + Value: sessionId, + Path: "/", + MaxAge: 96 * 3600, // fixme, брать из db + }) + + api_helpers.RespondWithJSON(w, http.StatusOK, map[string]string{"data": "User logged in"}) +} + +func LogoutSessionHandler(db *sql.DB, w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_id") + if err != nil { + http.Error(w, "No session found", http.StatusUnauthorized) + return + } + sessionRepo := repository.NewSessionRepository(db) + err = sessionRepo.DeleteSessionInDB(r.Context(), cookie.Value) + if err != nil { + http.Error(w, "Failed to delete session", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: "", + Path: "/", + MaxAge: -1, // Удаление куки + }) + api_helpers.RespondWithJSON(w, http.StatusOK, map[string]string{"data": "User logout successful"}) +} diff --git a/internal/app/repository/session.go b/internal/app/repository/session.go new file mode 100644 index 0000000..5325577 --- /dev/null +++ b/internal/app/repository/session.go @@ -0,0 +1,54 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +type SessionRepository interface { + GetSessionFromDB(ctx context.Context, sessionId string) (int, error) + StoreSessionInDB(ctx context.Context, userId int) (string, error) + DeleteSessionInDB(ctx context.Context, cookie string) error +} + +type sessionRepo struct { + db *sql.DB +} + +func NewSessionRepository(db *sql.DB) SessionRepository { + return &sessionRepo{db: db} +} + +func (r *sessionRepo) GetSessionFromDB(ctx context.Context, sessionId string) (int, error) { + var userId int + err := r.db.QueryRowContext(ctx, "SELECT user_id FROM sessions WHERE id = $1 AND expires_at > NOW()", sessionId).Scan(&userId) + return userId, err +} + +func (r *sessionRepo) StoreSessionInDB(ctx context.Context, userId int) (string, error) { + var session string + query := ` + INSERT INTO sessions (user_id, expires_at) + VALUES ($1, $2) + ON CONFLICT (user_id) DO + UPDATE SET expires_at = EXCLUDED.expires_at + RETURNING id + ` + err := r.db.QueryRowContext(ctx, query, userId, time.Now().Add(96*time.Hour)).Scan(&session) + fmt.Println(session) + if err != nil { + return "", err + } + return session, nil +} + +func (r *sessionRepo) DeleteSessionInDB(ctx context.Context, sessionId string) error { + query := "DELETE FROM sessions where id = $1" + _, err := r.db.ExecContext(ctx, query, sessionId) + if err != nil { + return err + } + return nil +} diff --git a/internal/app/repository/user.go b/internal/app/repository/user.go index 058c655..e85824c 100644 --- a/internal/app/repository/user.go +++ b/internal/app/repository/user.go @@ -10,6 +10,7 @@ type UserRepository interface { Create(ctx context.Context, user *models.User) error AddUserToTeams(ctx context.Context, userId int, teamIds []string) error GetById(ctx context.Context, id string) (*models.User, error) + GetByUserName(ctx context.Context, id string) (*models.User, error) Update(ctx context.Context, user *models.User) error Delete(ctx context.Context, id string) error List(ctx context.Context) ([]*models.User, error) @@ -52,6 +53,16 @@ func (r *userRepo) GetById(ctx context.Context, id string) (*models.User, error) return user, nil } +func (r *userRepo) GetByUserName(ctx context.Context, name string) (*models.User, error) { + query := `SELECT id, password_hash FROM users WHERE user_name = $1` + user := &models.User{} + err := r.db.QueryRowContext(ctx, query, name).Scan(&user.Id, &user.PasswordHash) + if err != nil { + return nil, err + } + return user, nil +} + func (r *userRepo) Update(ctx context.Context, user *models.User) error { query := `UPDATE users SET user_name = $1, avatar_url = $2, role = $3 WHERE id = $4` _, err := r.db.ExecContext(ctx, query, user.Username, user.AvatarUrl, user.Role, user.Id) diff --git a/internal/app/routers/routers.go b/internal/app/routers/routers.go index 5d1236c..0bbdc2c 100644 --- a/internal/app/routers/routers.go +++ b/internal/app/routers/routers.go @@ -89,6 +89,9 @@ var apiRoutes = ApiRoutes{ ApiRoute{"ListUsers", strings.ToUpper("Get"), "/api/users", api.ListUsersHandler}, ApiRoute{"UpdateUser", strings.ToUpper("Put"), "/api/users/{id}", api.UpdateUserHandler}, + ApiRoute{"LoginUser", strings.ToUpper("Post"), "/api/users/login", api.LoginSessionHandler}, + ApiRoute{"LogoutUser", strings.ToUpper("Post"), "/api/users/logout", api.LogoutSessionHandler}, + ApiRoute{"ListUniversities", strings.ToUpper("Get"), "/api/universities", api.ListUniversitiesHandler}, } diff --git a/migrations/init.sql b/migrations/init.sql index 2af368d..18df1e7 100644 --- a/migrations/init.sql +++ b/migrations/init.sql @@ -8,6 +8,14 @@ CREATE TABLE users ( role VARCHAR(255) NOT NULL CHECK (role IN ('admin', 'player', 'guest')) ); +-- Сессии пользователей +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE TABLE sessions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id INTEGER UNIQUE REFERENCES users(id), + expires_at TIMESTAMP NOT NULL +); + -- Университеты CREATE TABLE universities ( id SERIAL PRIMARY KEY, diff --git a/web/templates/index.html b/web/templates/index.html index fb369ec..4bd0488 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -36,7 +36,8 @@ text-decoration: underline; } - + +