// Package auth хранит учётные записи панели и живые сессии. package auth import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // SessionCookie — имя cookie с идентификатором сессии. const SessionCookie = "pzm_session" // Ошибки, которые вызывающему коду имеет смысл различать. var ( ErrBadCredentials = errors.New("неверный логин или пароль") ErrUserExists = errors.New("пользователь уже существует") ErrUserNotFound = errors.New("пользователь не найден") ) // User — учётная запись веб-панели. type User struct { Login string `json:"login"` Hash string `json:"hash"` CreatedAt time.Time `json:"created_at"` } type session struct { login string expiresAt time.Time } // Store — потокобезопасное хранилище пользователей (файл на диске) и // сессий (только в памяти: перезапуск менеджера разлогинивает всех). type Store struct { path string ttl time.Duration mu sync.RWMutex users map[string]User sessions map[string]session } // NewStore открывает файл с пользователями, создавая пустой при первом запуске. func NewStore(path string, ttl time.Duration) (*Store, error) { s := &Store{ path: path, ttl: ttl, users: make(map[string]User), sessions: make(map[string]session), } if err := s.load(); err != nil { return nil, err } return s, nil } func (s *Store) load() error { raw, err := os.ReadFile(s.path) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("прочитать %s: %w", s.path, err) } var users []User if err := json.Unmarshal(raw, &users); err != nil { return fmt.Errorf("разобрать %s: %w", s.path, err) } for _, u := range users { s.users[u.Login] = u } return nil } // saveLocked пишет пользователей на диск; вызывается под удержанным s.mu. func (s *Store) saveLocked() error { users := make([]User, 0, len(s.users)) for _, u := range s.users { users = append(users, u) } raw, err := json.MarshalIndent(users, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } tmp := s.path + ".tmp" // 0600: файл содержит хеши паролей. if err := os.WriteFile(tmp, raw, 0o600); err != nil { return err } return os.Rename(tmp, s.path) } // IsEmpty сообщает, что учёток нет и панель нужно инициализировать. func (s *Store) IsEmpty() bool { s.mu.RLock() defer s.mu.RUnlock() return len(s.users) == 0 } // Logins возвращает отсортированный список логинов. func (s *Store) Logins() []string { s.mu.RLock() defer s.mu.RUnlock() out := make([]string, 0, len(s.users)) for login := range s.users { out = append(out, login) } return out } // AddUser создаёт учётку. Пароль хешируется bcrypt'ом. func (s *Store) AddUser(login, password string) error { login = strings.TrimSpace(login) if login == "" { return errors.New("логин не может быть пустым") } if len(password) < 8 { return errors.New("пароль короче 8 символов") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if _, exists := s.users[login]; exists { return ErrUserExists } s.users[login] = User{Login: login, Hash: string(hash), CreatedAt: time.Now()} return s.saveLocked() } // SetPassword меняет пароль существующей учётки. func (s *Store) SetPassword(login, password string) error { if len(password) < 8 { return errors.New("пароль короче 8 символов") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[login] if !ok { return ErrUserNotFound } u.Hash = string(hash) s.users[login] = u return s.saveLocked() } // DeleteUser удаляет учётку и разлогинивает её сессии. func (s *Store) DeleteUser(login string) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.users[login]; !ok { return ErrUserNotFound } delete(s.users, login) for id, sess := range s.sessions { if sess.login == login { delete(s.sessions, id) } } return s.saveLocked() } // Login проверяет пару логин/пароль и заводит сессию, возвращая её id. func (s *Store) Login(login, password string) (string, error) { s.mu.RLock() u, ok := s.users[login] s.mu.RUnlock() if !ok { // Считаем фиктивный хеш, чтобы по времени ответа нельзя было // отличить несуществующий логин от неверного пароля. bcrypt.CompareHashAndPassword([]byte("$2a$10$"+strings.Repeat("x", 53)), []byte(password)) return "", ErrBadCredentials } if err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)); err != nil { return "", ErrBadCredentials } id, err := randomID() if err != nil { return "", err } s.mu.Lock() s.sessions[id] = session{login: login, expiresAt: time.Now().Add(s.ttl)} s.mu.Unlock() return id, nil } // Logout закрывает конкретную сессию. func (s *Store) Logout(id string) { s.mu.Lock() delete(s.sessions, id) s.mu.Unlock() } // Validate возвращает логин владельца сессии, продлевая её срок жизни. func (s *Store) Validate(id string) (string, bool) { if id == "" { return "", false } s.mu.Lock() defer s.mu.Unlock() sess, ok := s.sessions[id] if !ok { return "", false } if time.Now().After(sess.expiresAt) { delete(s.sessions, id) return "", false } sess.expiresAt = time.Now().Add(s.ttl) s.sessions[id] = sess return sess.login, true } // GC выкидывает протухшие сессии; вызывается по таймеру. func (s *Store) GC() { now := time.Now() s.mu.Lock() defer s.mu.Unlock() for id, sess := range s.sessions { if now.After(sess.expiresAt) { delete(s.sessions, id) } } } // SessionFromRequest достаёт логин из cookie запроса. func (s *Store) SessionFromRequest(r *http.Request) (string, bool) { c, err := r.Cookie(SessionCookie) if err != nil { return "", false } return s.Validate(c.Value) } func randomID() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil }