228 lines
6.3 KiB
Go
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
|
|
}
|