Files
2026-07-15 11:41:30 +08:00

228 lines
6.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed 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 security
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// DataFileName is the encrypted token data file name.
const DataFileName = ".data"
// TokenData mirrors auth.TokenData for the security layer.
type TokenData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
PersistentCode string `json:"persistent_code"`
ExpiresAt time.Time `json:"expires_at"`
RefreshExpAt time.Time `json:"refresh_expires_at"`
CorpID string `json:"corp_id"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
CorpName string `json:"corp_name,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Source string `json:"source,omitempty"`
}
// SecureTokenStorage provides encrypted token persistence using MAC.
type SecureTokenStorage struct {
configDir string
fallbackDir string
macAddr string
}
type tokenTempFile interface {
Write([]byte) (int, error)
Sync() error
Close() error
}
var (
mkdirTokenDir = os.MkdirAll
marshalTokenData = json.Marshal
encryptTokenData = Encrypt
openTokenTempFile = func(path string, flag int, perm os.FileMode) (tokenTempFile, error) {
return os.OpenFile(path, flag, perm)
}
removeTokenFile = os.Remove
renameTokenFile = os.Rename
readTokenFile = os.ReadFile
decryptTokenData = Decrypt
unmarshalTokenData = json.Unmarshal
)
// NewSecureTokenStorage creates a new secure storage instance.
// fallbackDir may be empty.
func NewSecureTokenStorage(configDir, fallbackDir, macAddr string) *SecureTokenStorage {
return &SecureTokenStorage{
configDir: configDir,
fallbackDir: fallbackDir,
macAddr: macAddr,
}
}
func (s *SecureTokenStorage) password() []byte {
return []byte(s.macAddr)
}
// DataDirs returns all configured data directories.
func (s *SecureTokenStorage) DataDirs() []string {
var out []string
if s.configDir != "" {
out = append(out, s.configDir)
}
if s.fallbackDir != "" {
out = append(out, s.fallbackDir)
}
return out
}
func dataPath(dir string) string {
return filepath.Join(dir, DataFileName)
}
// Exists checks whether an encrypted data file exists in any configured dir.
func (s *SecureTokenStorage) Exists() bool {
if st, err := os.Stat(dataPath(s.configDir)); err == nil && !st.IsDir() {
return true
}
if s.fallbackDir != "" {
if st, err := os.Stat(dataPath(s.fallbackDir)); err == nil && !st.IsDir() {
return true
}
}
return false
}
// DataFileExistsInAny checks whether .data exists in any of the given dirs.
func DataFileExistsInAny(dirs ...string) bool {
for _, dir := range dirs {
if dir == "" {
continue
}
if st, err := os.Stat(dataPath(dir)); err == nil && !st.IsDir() {
return true
}
}
return false
}
// SaveToken encrypts and persists token data using atomic write with fsync.
func (s *SecureTokenStorage) SaveToken(data *TokenData) error {
if err := mkdirTokenDir(s.configDir, config.DirPerm); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
b, err := marshalTokenData(data)
if err != nil {
return fmt.Errorf("marshaling token: %w", err)
}
enc, err := encryptTokenData(b, s.password())
if err != nil {
return fmt.Errorf("encrypting token: %w", err)
}
finalPath := dataPath(s.configDir)
tmpPath := finalPath + ".tmp"
// Atomic write with fsync to ensure data durability
tmpFile, err := openTokenTempFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
writeSuccess := false
defer func() {
if !writeSuccess {
tmpFile.Close()
_ = removeTokenFile(tmpPath)
}
}()
if _, err := tmpFile.Write(enc); err != nil {
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("syncing temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := renameTokenFile(tmpPath, finalPath); err != nil {
_ = removeTokenFile(tmpPath)
return fmt.Errorf("renaming to final: %w", err)
}
writeSuccess = true
return nil
}
// LoadToken reads and decrypts .data; tries configDir first, then fallbackDir.
func (s *SecureTokenStorage) LoadToken() (*TokenData, error) {
var raw []byte
var err error
raw, err = readTokenFile(dataPath(s.configDir))
if err != nil && s.fallbackDir != "" {
raw, err = readTokenFile(dataPath(s.fallbackDir))
}
if err != nil {
return nil, fmt.Errorf("reading encrypted file: %w", err)
}
plain, err := decryptTokenData(raw, s.password())
if err != nil {
return nil, err
}
var data TokenData
if err := unmarshalTokenData(plain, &data); err != nil {
return nil, fmt.Errorf("parsing token JSON: %w", err)
}
return &data, nil
}
// DeleteToken removes encrypted data files from all configured dirs.
func (s *SecureTokenStorage) DeleteToken() error {
var firstErr error
for _, dir := range []string{s.configDir, s.fallbackDir} {
if dir == "" {
continue
}
p := dataPath(dir)
if err := removeTokenFile(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
firstErr = fmt.Errorf("deleting %s: %w", p, err)
}
_ = removeTokenFile(p + ".tmp")
}
return firstErr
}
// DeleteEncryptedData removes .data files from the given directories.
func DeleteEncryptedData(configDir string, fallbackDirs ...string) error {
var firstErr error
dirs := append([]string{configDir}, fallbackDirs...)
for _, dir := range dirs {
if dir == "" {
continue
}
p := dataPath(dir)
if err := removeTokenFile(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
firstErr = fmt.Errorf("deleting %s: %w", p, err)
}
_ = removeTokenFile(p + ".tmp")
}
return firstErr
}