tart login: verify credentials (#102)

* tart login: verify credentials

* Make DictionaryCredentialsProvider fileprivate to Login.swift
This commit is contained in:
Nikolay Edigaryev 2022-05-26 16:31:46 +03:00 committed by GitHub
parent afa6b7b46c
commit f3068b9055
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 140 additions and 90 deletions

View File

@ -10,9 +10,21 @@ struct Login: AsyncParsableCommand {
func run() async throws {
do {
let (user, password) = try Credentials.retrieveStdin()
let (user, password) = try StdinCredentials.retrieve()
let credentialsProvider = DictionaryCredentialsProvider([
host: (user, password)
])
try Credentials.store(host: host, user: user, password: password)
do {
let registry = try Registry(host: host, namespace: "", credentialsProvider: credentialsProvider)
try await registry.ping()
} catch {
print("invalid credentials: \(error)")
Foundation.exit(1)
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
Foundation.exit(0)
} catch {
@ -22,3 +34,19 @@ struct Login: AsyncParsableCommand {
}
}
}
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
self.credentials = credentials
}
func retrieve(host: String) throws -> (String, String)? {
credentials[host]
}
func store(host: String, user: String, password: String) throws {
credentials[host] = (user, password)
}
}

View File

@ -1,82 +0,0 @@
import Foundation
enum CredentialsError: Error {
case CredentialRequired(which: String)
case CredentialTooLong(message: String)
}
class Credentials {
static func retrieveKeychain(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw RegistryError.AuthFailed(why: "Keychain item has unexpected format")
}
return (user, password)
}
static func retrieveStdin() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw CredentialsError.CredentialRequired(which: name)
}
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
if credential.count > maxCharacters {
throw CredentialsError.CredentialTooLong(
message: "\(name) should contain no more than \(maxCharacters) characters")
}
return credential
}
static func store(host: String, user: String, password: String) throws {
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecValueData as String: password,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess, errSecDuplicateItem:
return
default:
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
}
}
}

View File

@ -0,0 +1,10 @@
import Foundation
enum CredentialsProviderError: Error {
case Failed(message: String)
}
protocol CredentialsProvider {
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}

View File

@ -0,0 +1,54 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
}
func store(host: String, user: String, password: String) throws {
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecValueData as String: password,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess, errSecDuplicateItem:
return
default:
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
}
}

View File

@ -0,0 +1,31 @@
import Foundation
enum StdinCredentialsError: Error {
case CredentialRequired(which: String)
case CredentialTooLong(message: String)
}
class StdinCredentials {
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw StdinCredentialsError.CredentialRequired(which: name)
}
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
if credential.count > maxCharacters {
throw StdinCredentialsError.CredentialTooLong(
message: "\(name) should contain no more than \(maxCharacters) characters")
}
return credential
}
}

View File

@ -79,24 +79,33 @@ class Registry {
try! httpClient.syncShutdown()
}
var baseURL: URL
var namespace: String
let baseURL: URL
let namespace: String
let credentialsProvider: CredentialsProvider
var currentAuthToken: TokenResponse? = nil
init(urlComponents: URLComponents, namespace: String) throws {
init(urlComponents: URLComponents,
namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
self.credentialsProvider = credentialsProvider
}
convenience init(host: String, namespace: String) throws {
convenience init(
host: String,
namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
) throws {
var baseURLComponents = URLComponents()
baseURLComponents.scheme = "https"
baseURLComponents.host = host
baseURLComponents.path = "/v2/"
try self.init(urlComponents: baseURLComponents, namespace: namespace)
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
}
func ping() async throws {
@ -285,7 +294,7 @@ class Registry {
var headers: Dictionary<String, String> = Dictionary()
if let (user, password) = try Credentials.retrieveKeychain(host: baseURL.host!) {
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)"
}