blob: b704d1bb4b92f7c3543638fd5fae97e1e15bff14 [file]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth
import (
"context"
"sync"
"time"
"github.com/apache/incubator-devlake/core/dal"
"github.com/apache/incubator-devlake/core/log"
)
// revocationRefreshInterval is how often the cache reloads the revoked-jti
// set from the database. Revocations propagate within this window.
const revocationRefreshInterval = 30 * time.Second
// revocationCache holds the set of revoked session jtis. The MVP keeps it
// fully in memory because the set is tiny (only sessions that were
// explicitly revoked, pruned on natural expiry) and per-request DB lookups
// would otherwise dominate the auth middleware's hot path.
type revocationCache struct {
mu sync.RWMutex
revoked map[string]struct{}
}
func newRevocationCache() *revocationCache {
return &revocationCache{revoked: map[string]struct{}{}}
}
func (r *revocationCache) IsRevoked(jti string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.revoked[jti]
return ok
}
func (r *revocationCache) Add(jti string) {
r.mu.Lock()
r.revoked[jti] = struct{}{}
r.mu.Unlock()
}
// Refresh reloads the revoked set from the DB while preserving any Adds
// that landed concurrently with the DB read. The race we're guarding:
// Logout calls RevokeSession then revoked.Add; if Refresh's DB read happens
// between those two steps, the new map would be missing the jti. Carrying
// forward "additions since the snapshot" plugs that gap.
func (r *revocationCache) Refresh(db dal.Dal) error {
r.mu.RLock()
before := make(map[string]struct{}, len(r.revoked))
for j := range r.revoked {
before[j] = struct{}{}
}
r.mu.RUnlock()
jtis, err := ListRevoked(db)
if err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
next := make(map[string]struct{}, len(jtis))
for _, j := range jtis {
next[j] = struct{}{}
}
// Anything in the current set that wasn't in the snapshot was Added
// during the DB read; keep it. Anything in both `before` and the
// current set but absent from the DB result has expired naturally and
// is safe to drop.
for j := range r.revoked {
if _, wasBefore := before[j]; !wasBefore {
next[j] = struct{}{}
}
}
r.revoked = next
return nil
}
// startRefresher loads the cache once synchronously, then refreshes it on a
// timer. The synchronous first load means we never accept revoked sessions
// in a startup window.
func startRefresher(ctx context.Context, cache *revocationCache, db dal.Dal, logger log.Logger) {
if err := cache.Refresh(db); err != nil {
logger.Error(err, "auth: initial revocation cache load failed")
}
go func() {
t := time.NewTicker(revocationRefreshInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := cache.Refresh(db); err != nil {
logger.Error(err, "auth: revocation cache refresh failed")
}
}
}
}()
}