feat(api): add apikey support (#408)

* feat(api): add apikey support

* feat(web): api settings crud
This commit is contained in:
ze0s 2022-08-15 11:58:13 +02:00 committed by GitHub
parent 9c036033e9
commit fa20978d58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 834 additions and 70 deletions

91
internal/api/service.go Normal file
View file

@ -0,0 +1,91 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/logger"
"github.com/rs/zerolog"
)
type Service interface {
List(ctx context.Context) ([]domain.APIKey, error)
Store(ctx context.Context, key *domain.APIKey) error
Update(ctx context.Context, key *domain.APIKey) error
Delete(ctx context.Context, key string) error
ValidateAPIKey(ctx context.Context, token string) bool
}
type service struct {
log zerolog.Logger
repo domain.APIRepo
keyCache []domain.APIKey
}
func NewService(log logger.Logger, repo domain.APIRepo) Service {
return &service{
log: log.With().Str("module", "api").Logger(),
repo: repo,
keyCache: []domain.APIKey{},
}
}
func (s *service) List(ctx context.Context) ([]domain.APIKey, error) {
if len(s.keyCache) > 0 {
return s.keyCache, nil
}
return s.repo.GetKeys(ctx)
}
func (s *service) Store(ctx context.Context, key *domain.APIKey) error {
key.Key = generateSecureToken(16)
if err := s.repo.Store(ctx, key); err != nil {
return err
}
if len(s.keyCache) > 0 {
// set new key
s.keyCache = append(s.keyCache, *key)
}
return nil
}
func (s *service) Update(ctx context.Context, key *domain.APIKey) error {
return nil
}
func (s *service) Delete(ctx context.Context, key string) error {
// reset
s.keyCache = []domain.APIKey{}
return s.repo.Delete(ctx, key)
}
func (s *service) ValidateAPIKey(ctx context.Context, key string) bool {
keys, err := s.repo.GetKeys(ctx)
if err != nil {
return false
}
for _, k := range keys {
if k.Key == key {
return true
}
}
return false
}
func generateSecureToken(length int) string {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}

114
internal/database/api.go Normal file
View file

@ -0,0 +1,114 @@
package database
import (
"context"
"database/sql"
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/lib/pq"
"github.com/rs/zerolog"
)
func NewAPIRepo(log logger.Logger, db *DB) domain.APIRepo {
return &APIRepo{
log: log.With().Str("repo", "api").Logger(),
db: db,
}
}
type APIRepo struct {
log zerolog.Logger
db *DB
cache map[string]domain.APIKey
}
func (r *APIRepo) Store(ctx context.Context, key *domain.APIKey) error {
queryBuilder := r.db.squirrel.
Insert("api_key").
Columns(
"name",
"key",
"scopes",
).
Values(
key.Name,
key.Key,
pq.Array(key.Scopes),
).
Suffix("RETURNING created_at").RunWith(r.db.handler)
var createdAt time.Time
if err := queryBuilder.QueryRowContext(ctx).Scan(&createdAt); err != nil {
return errors.Wrap(err, "error executing query")
}
key.CreatedAt = createdAt
return nil
}
func (r *APIRepo) Delete(ctx context.Context, key string) error {
queryBuilder := r.db.squirrel.
Delete("api_key").
Where("key = ?", key)
query, args, err := queryBuilder.ToSql()
if err != nil {
return errors.Wrap(err, "error building query")
}
_, err = r.db.handler.ExecContext(ctx, query, args...)
if err != nil {
return errors.Wrap(err, "error executing query")
}
r.log.Debug().Msgf("successfully deleted: %v", key)
return nil
}
func (r *APIRepo) GetKeys(ctx context.Context) ([]domain.APIKey, error) {
queryBuilder := r.db.squirrel.
Select(
"name",
"key",
"scopes",
"created_at",
).
From("api_key")
query, args, err := queryBuilder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "error building query")
}
rows, err := r.db.handler.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.Wrap(err, "error executing query")
}
defer rows.Close()
keys := make([]domain.APIKey, 0)
for rows.Next() {
var a domain.APIKey
var name sql.NullString
if err := rows.Scan(&name, &a.Key, pq.Array(&a.Scopes), &a.CreatedAt); err != nil {
return nil, errors.Wrap(err, "error scanning row")
}
a.Name = name.String
keys = append(keys, a)
}
return keys, nil
}

View file

@ -315,6 +315,14 @@ CREATE TABLE feed_cache
value TEXT,
ttl TIMESTAMP
);
CREATE TABLE api_key
(
name TEXT,
key TEXT PRIMARY KEY,
scopes TEXT [] DEFAULT '{}' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
var postgresMigrations = []string{
@ -541,4 +549,12 @@ CREATE INDEX indexer_identifier_index
ALTER TABLE filter
ADD COLUMN except_origins TEXT [] DEFAULT '{}';
`,
`CREATE TABLE api_key
(
name TEXT,
key TEXT PRIMARY KEY,
scopes TEXT [] DEFAULT '{}' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`,
}

View file

@ -298,6 +298,14 @@ CREATE TABLE feed_cache
value TEXT,
ttl TIMESTAMP
);
CREATE TABLE api_key
(
name TEXT,
key TEXT PRIMARY KEY,
scopes TEXT [] DEFAULT '{}' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
var sqliteMigrations = []string{
@ -861,4 +869,12 @@ CREATE INDEX indexer_identifier_index
ALTER TABLE filter
ADD COLUMN except_origins TEXT [] DEFAULT '{}';
`,
`CREATE TABLE api_key
(
name TEXT,
key TEXT PRIMARY KEY,
scopes TEXT [] DEFAULT '{}' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`,
}

19
internal/domain/api.go Normal file
View file

@ -0,0 +1,19 @@
package domain
import (
"context"
"time"
)
type APIRepo interface {
Store(ctx context.Context, key *APIKey) error
Delete(ctx context.Context, key string) error
GetKeys(ctx context.Context) ([]APIKey, error)
}
type APIKey struct {
Name string `json:"name"`
Key string `json:"key"`
Scopes []string `json:"scopes"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -8,7 +8,7 @@ import (
"strconv"
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type actionService interface {

78
internal/http/apikey.go Normal file
View file

@ -0,0 +1,78 @@
package http
import (
"context"
"encoding/json"
"net/http"
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type apikeyService interface {
List(ctx context.Context) ([]domain.APIKey, error)
Store(ctx context.Context, key *domain.APIKey) error
Update(ctx context.Context, key *domain.APIKey) error
Delete(ctx context.Context, key string) error
ValidateAPIKey(ctx context.Context, token string) bool
}
type apikeyHandler struct {
encoder encoder
service apikeyService
}
func newAPIKeyHandler(encoder encoder, service apikeyService) *apikeyHandler {
return &apikeyHandler{
encoder: encoder,
service: service,
}
}
func (h apikeyHandler) Routes(r chi.Router) {
r.Get("/", h.list)
r.Post("/", h.store)
r.Delete("/{apikey}", h.delete)
}
func (h apikeyHandler) list(w http.ResponseWriter, r *http.Request) {
keys, err := h.service.List(r.Context())
if err != nil {
h.encoder.Error(w, err)
return
}
render.JSON(w, r, keys)
}
func (h apikeyHandler) store(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
data domain.APIKey
)
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
// encode error
h.encoder.StatusInternalError(w)
return
}
if err := h.service.Store(ctx, &data); err != nil {
// encode error
h.encoder.StatusInternalError(w)
return
}
h.encoder.StatusResponse(ctx, w, data, http.StatusCreated)
}
func (h apikeyHandler) delete(w http.ResponseWriter, r *http.Request) {
if err := h.service.Delete(r.Context(), chi.URLParam(r, "apikey")); err != nil {
h.encoder.StatusInternalError(w)
return
}
h.encoder.NoContent(w)
}

View file

@ -5,7 +5,7 @@ import (
"encoding/json"
"net/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/autobrr/autobrr/internal/domain"

View file

@ -3,7 +3,7 @@ package http
import (
"net/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type configJson struct {

View file

@ -7,7 +7,7 @@ import (
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/autobrr/autobrr/internal/domain"
)

View file

@ -15,7 +15,7 @@ type errorResponse struct {
func (e encoder) StatusResponse(ctx context.Context, w http.ResponseWriter, response interface{}, status int) {
if response != nil {
w.Header().Set("Content-Type", "application/json; charset=utf=8")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(response); err != nil {
w.WriteHeader(http.StatusInternalServerError)
@ -30,6 +30,15 @@ func (e encoder) StatusCreated(w http.ResponseWriter) {
w.WriteHeader(http.StatusCreated)
}
func (e encoder) StatusCreatedData(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(data); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (e encoder) NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
@ -47,7 +56,7 @@ func (e encoder) Error(w http.ResponseWriter, err error) {
Message: err.Error(),
}
w.Header().Set("Content-Type", "application/json; charset=utf=8")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(res)
}

View file

@ -8,7 +8,7 @@ import (
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type feedService interface {

View file

@ -6,7 +6,7 @@ import (
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/autobrr/autobrr/internal/domain"
)

View file

@ -5,7 +5,7 @@ import (
"github.com/autobrr/autobrr/internal/database"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type healthHandler struct {

View file

@ -8,7 +8,7 @@ import (
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type indexerService interface {

View file

@ -6,7 +6,7 @@ import (
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/autobrr/autobrr/internal/domain"
)

View file

@ -4,14 +4,30 @@ import "net/http"
func (s Server) IsAuthenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// check session
session, _ := s.cookieStore.Get(r, "user_session")
if token := r.Header.Get("X-API-Token"); token != "" {
// check header
if !s.apiService.ValidateAPIKey(r.Context(), token) {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
// Check if user is authenticated
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
} else if key := r.URL.Query().Get("apikey"); key != "" {
// check query param lke ?apikey=TOKEN
if !s.apiService.ValidateAPIKey(r.Context(), key) {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
} else {
// check session
session, _ := s.cookieStore.Get(r, "user_session")
// Check if user is authenticated
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}

View file

@ -8,7 +8,7 @@ import (
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type notificationService interface {

View file

@ -7,7 +7,7 @@ import (
"strconv"
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
)
type releaseService interface {

View file

@ -10,7 +10,8 @@ import (
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/web"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/gorilla/sessions"
"github.com/r3labs/sse/v2"
"github.com/rs/cors"
@ -28,6 +29,7 @@ type Server struct {
date string
actionService actionService
apiService apikeyService
authService authService
downloadClientService downloadClientService
filterService filterService
@ -38,7 +40,7 @@ type Server struct {
releaseService releaseService
}
func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server {
func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server {
return Server{
config: config,
sse: sse,
@ -50,6 +52,7 @@ func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version
cookieStore: sessions.NewCookieStore([]byte(config.SessionSecret)),
actionService: actionService,
apiService: apiService,
authService: authService,
downloadClientService: downloadClientSvc,
filterService: filterSvc,
@ -78,6 +81,10 @@ func (s Server) Open() error {
func (s Server) Handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
c := cors.New(cors.Options{
AllowCredentials: true,
AllowedMethods: []string{"HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"},
@ -116,6 +123,7 @@ func (s Server) Handler() http.Handler {
r.Route("/feeds", newFeedHandler(encoder, s.feedService).Routes)
r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes)
r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes)
r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).Routes)
r.Route("/notification", newNotificationHandler(encoder, s.notificationService).Routes)
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)